26 KiB
Context
Every prior milestone — device-management/driver-registry (apex-agent-mvp, device-agent-runtime-foundation), agent-runtime, semantic-scene, world-model, skill-authoring/skill-versioning/skill-embedding-retrieval, workflow-orchestration, multi-agent-collaboration — is designed and (mostly) built around one process: one DeviceManager instance holding an in-memory dict of devices, one TaskRunner/WorkflowRunner executing on whichever device_id the caller passes in, and one driver/registry.py extension point (SUPPORTED_DRIVER_TYPES) that a caller reads to build a driver by name. That single-process model is a deliberate, explicit non-goal-turned-goal in apex-agent-mvp/design.md ("No production task queue ... synchronous, single-process execution is sufficient for the MVP") and every later milestone has respected it rather than re-litigating it — workflow-orchestration-runtime's own Non-Goals state plainly: "No distributed/multi-device workflow execution or cross-node coordination — that is Milestone 10 (Cloud Runtime)."
This change is that milestone. Three things are genuinely new here that no prior milestone had to solve: (1) device state now spans more than one host process, so "list devices" and "is this device idle" can no longer be answered by one in-memory dict; (2) a caller submitting a task no longer names a device_id up front — something has to pick one, from a fleet, based on constraints; (3) new drivers/tools/skills should be addable without editing this repository's packages at all, which the existing "add a dict entry to driver/registry.py" extension point does not yet support for a party who cannot modify the repo.
Two stakeholders: an external integrator (a system outside this repo, e.g. a customer's backend) who needs a stable, versioned API to submit work and query status without going through the LLM-facing MCP surface or the operator-facing web console; and a fleet operator who runs more than one host process (each with its own DeviceManager and set of physically-attached devices) and needs one place to see "how many devices are idle across all of them" and have tasks land on whichever is free.
Goals / Non-Goals
Goals:
- Define an internal
DevicePoolmodel that aggregates device state from potentially many hosts' independentDeviceManagerinstances, degrading gracefully (marking a host's devicesunreachable, never crashing or blocking) when a host stops reporting in. - Define a
TaskSchedulerthat accepts task submissions with optional device constraints and assigns them to an idle, matchingPooledDevice, via a pluggable, registrableAssignmentStrategy— the same "string key → swappable implementation" shape asdriver/registry.py'sSUPPORTED_DRIVER_TYPESandworkflow-orchestration-runtime'sConditionEvaluatorregistry. - Define a
TaskDispatcherthat actually executes a scheduler assignment by composing the existingagent-runtime/workflow-orchestrationentry points — so device selection becomes automatic without duplicating any execution logic those capabilities already own. - Define a
PluginManifest/PluginRegistrymechanism so a new driver (and, in schema if not yet in wiring, a new tool or skill) can register itself via a manifest file or Python entry point, without editingdriver/,device/,tools/, or the skill stores. - Define a versioned, public
platform-sdk(REST API + Python client) for external integrators, clearly scoped apart frommcp-tool-server(LLM tool-calling surface) andconsole-status-api/console-config-api(single-process operator UI surface).
Non-Goals:
- No actual multi-host deployment or infrastructure tooling (no Docker/Kubernetes manifests, no service mesh, no real network transport implementation for host-to-pool sync beyond defining the
sync_host_devices(host_id, snapshot)call signature this change's local reference implementation exercises in-process). This change defines the runtime's internal data model and control flow for a device pool and scheduler, not ops tooling to actually run one across real machines. - No cross-host task execution dispatch over the network:
TaskDispatchercomposesTaskRunner/WorkflowRunnerdirectly, which only works when the assigned device is owned by the same process the dispatcher runs in. WhenDevicePoolreports aPooledDeviceowned by a different host,TaskDispatcherraises a clear, typedRemoteDispatchNotSupportedErrorrather than silently no-op'ing or pretending to execute — real cross-host RPC dispatch is left as an Open Question / follow-up, consistent with this change's own "no infra" non-goal. - No billing, multi-tenant authentication/authorization, or rate limiting on
platform-sdk— a later change's concern; this change may add a single API-key placeholder hook (see D8) but does not implement real auth. - No generic dynamic tool/skill plugin wiring —
plugin-system's manifest schema acceptstool/skill-kind entries for forward compatibility, but onlydriver-kind plugins are concretely registered into an existing extension point (driver-registry) in this change;tools/and the skill stores have no comparable public registration hook yet to compose against. - No changes to
driver/,device/,runtime/,tools/,workflow/,agents/,storage/,api/console.py, orapi/mcp.py— every integration is by import/composition only. - No replacement of
mcp-tool-serverorconsole-status-api/console-config-api—platform-sdkis a third, additive surface, not a superset or a router in front of the other two.
Decisions
D1: New cloud/ package, sibling to agents//workflow//semantic//world//skills_learning/, not folded into runtime/, device/, or api/
cloud/ (pool.py, scheduler.py, dispatch.py, plugins.py, store.py, config.py, sdk/) is its own top-level package. This continues the one-package-per-milestone precedent (agents/ for Milestone 9, workflow/ for Milestone 8, semantic//world//skills_learning/ for Milestones 5–7) and keeps runtime/'s job (single-goal Observe-Think-Act loop) and device/'s job (one process's own device lifecycle) unchanged.
- Alternative considered: Extend
device/manager.py'sDeviceManagerin place to understand multiple hosts. Rejected —DeviceManageris a pending, unapplied capability (device-management) with no applied baseline inopenspec/specs/to safely diff aMODIFIEDdelta against in this session (the same reasoningworkflow-orchestration-runtime's D1 already applied toTaskRunner); treating it as a stable, composed-over dependency keeps "one process's device lifecycle" and "many processes' aggregated device state" as two separate, independently testable concerns, and avoids retroactively changing another change's still-pending contract. - Alternative considered: Put the four capabilities in four separate top-level packages (
device_pool/,task_scheduler/,plugins/,platform_sdk/). Rejected — these four are tightly coupled (the scheduler reads the pool; the SDK exposes both; the dispatcher needs the scheduler's assignment) and the roadmap already names this single milestone "Cloud Runtime"; one package with clear submodules keeps the coupling visible in the import graph instead of scattering it across four independent top-level packages that would need to depend on each other anyway.
D2: DevicePool aggregates by push, not by remotely calling each host's DeviceManager
DevicePool.sync_host_devices(host_id, snapshot: list[Device]) is called (by a periodic job, or by platform-sdk's host-registration endpoint) with the result of that host's own DeviceManager.list_devices() — the pool never reaches out over the network to call a remote DeviceManager method itself. HostRegistration.last_seen_at updates on every sync call; a background staleness check (or a check performed lazily on read, see D3) marks a host's PooledDevices unreachable once last_seen_at exceeds config.stale_after_seconds.
- Alternative considered: Give
DevicePoolan RPC client that calls a remote host'sDeviceManagerover HTTP/gRPC directly (pull model). Rejected for this change — building a real inter-host RPC layer is exactly the "actual multi-host deployment" infrastructure work this change's Non-Goals defer; a push-based sync call has a stable, host-agnostic signature today (sync_host_devices(host_id, snapshot)) that a later change can wire to any real transport (HTTP call from a lightweight per-host agent, message queue, etc.) without changingDevicePool's internal model.
D3: Host staleness is checked lazily on read, not via a background timer thread
DevicePool.list_devices() and DevicePool.get_device(device_id) compute each host's staleness (now - last_seen_at > stale_after_seconds) at call time and report unreachable status accordingly, rather than running a background thread that mutates stored status on a timer.
- Alternative considered: Run a background thread/asyncio task that periodically sweeps
HostRegistrations and flips stale ones tounreachablein the store. Rejected — a background thread adds lifecycle management (start/stop with the process, thread-safety withCloudStore's SQLite connections) for a property (staleness) that is cheap to compute at read time from a single stored timestamp; lazy computation is simpler, always consistent with the current clock, and avoids a whole class of "the sweep thread died silently" failure modes for no loss of correctness.
D4: TaskScheduler's queue and AssignmentStrategy registry mirror the Driver Registry / ConditionEvaluator pattern
cloud/scheduler.py defines AssignmentStrategy (one method, select(task: ScheduledTask, candidates: list[PooledDevice]) -> PooledDevice | None) and a registry mapping a strategy name (fifo_match default, reserved name capability_score for a future scoring strategy) to an implementation, the same "string key → pluggable implementation" shape already standardized on by driver/registry.py (device-agent-runtime-foundation D3), perception/provider.py (D8), and workflow/conditions.py's ConditionEvaluator (workflow-orchestration-runtime D6). TaskScheduler.submit(goal, constraints) enqueues a ScheduledTask{id, goal, constraints, status: queued|assigned|dispatched|done|failed}; TaskScheduler.assign() (called by a poll loop or triggered on submission/device-freed events) pops the head of the FIFO queue, asks DevicePool.list_devices() for idle candidates matching constraints.driver_type/constraints.capability_tags, and calls the configured AssignmentStrategy.select().
- Alternative considered: Hardcode FIFO-first-idle-match as the only assignment behavior with no strategy abstraction. Rejected — this is precisely the third occurrence of the same "pluggable-by-string-key" problem (driver selection, condition evaluation, now assignment strategy); making it a registry from the start costs one extra indirection and avoids an
if/elifchain being added later when a capability-scoring strategy is inevitably wanted.
D5: TaskDispatcher composes TaskRunner/WorkflowRunner as black boxes; local-only in this change
cloud/dispatch.py's TaskDispatcher.dispatch(assignment: Assignment) builds a core.models.Task(goal=assignment.task.goal, device_id=assignment.device_id) (or, if the submission specified a workflow_definition_id instead of a bare goal, a WorkflowDefinition) and calls the existing, unmodified runtime.task.TaskRunner(...).run(task) or workflow.runner.WorkflowRunner(...).run(definition) — exactly the composition pattern workflow-orchestration-runtime's D8 already established for WorkflowRunner composing TaskRunner. If assignment.host_id does not match the local process's own host id, dispatch() raises RemoteDispatchNotSupportedError instead of attempting anything — this change's scheduler can decide a remote host should take a task, but only a local dispatch is actually executed here (see Non-Goals and Open Questions).
- Alternative considered: Build a lower-level dispatch loop that re-implements plan/execute/retry logic inside
cloud/instead of callingTaskRunner/WorkflowRunner. Rejected — duplicates already-implemented, already-tested execution logic and creates two places that must stay behaviorally consistent; composing the existing public entry points automatically inherits any future improvement to either runner with zero change tocloud/.
D6: PluginManifest/PluginRegistry discovers via importlib.metadata entry points and a local manifest-file scan, both feeding one validation+registration path
cloud/plugins.py's PluginManifest (name: str, version: str, entry_point_kind: Literal["driver", "tool", "skill"], target: str — a dotted module:attribute path) can be produced two ways: (a) a package installed in the environment declares an entry point in group device_agent_runtime.plugins whose value is itself a PluginManifest-shaped object or a callable returning one; (b) a plugins/<name>/plugin.json file on a configured scan path is parsed directly into a PluginManifest. Both paths converge on PluginRegistry.register(manifest), which validates shape/uniqueness (no duplicate name, entry_point_kind recognized) and then, only for entry_point_kind == "driver", resolves target to a DriverFactoryBuilder callable and calls driver_registry.register_driver_type(manifest.name, builder).
- Alternative considered: Support only Python entry points (no manifest-file scan), matching a more conventional Python-plugin-ecosystem approach (e.g.,
pluggy). Rejected for this change — requiring every plugin author to publish an installable Python package with an entry point is a real adoption barrier for a first plugin mechanism; a plainplugin.jsonfile scan lets a plugin be "drop a folder inplugins/" as well, and both paths sharing one validation+registration function means neither is a second, divergent code path to maintain. - Note (dependency on a not-yet-existing function):
driver/registry.py(as specified by the pendingdriver-registrycapability) today only definesSUPPORTED_DRIVER_TYPES(a plain dict) andbuild_driver_factory(); it does not yet expose aregister_driver_type(name, builder)function. This change's driver-plugin registration path is written against that function by name, on the expectation thatdevice-agent-runtime-foundation's own stated intent ("adding a new driver type is a one entry addition todriver/registry.py") is realized as a small public function, not only as "edit the dict inline." This is called out again in Open Questions since it is a real dependency on another pending change's still-evolving surface, not a formal spec delta this change is making to it.
D7: cloud/store.py's CloudStore is a new, independently-owned SQLite file, not new tables in storage/task_metadata.py or workflow/store.py
CloudStore opens its own file (default cloud/cloud.sqlite3) with host_registrations, pooled_devices, scheduled_tasks, and plugins tables, using the same connect-per-call sqlite3 pattern as storage/task_metadata.py and workflow/store.py.
- Alternative considered: Add
scheduled_tasks/pooled_devicestables intoworkflow/store.py's existingworkflows.sqlite3(since a scheduled task's eventual execution may become aWorkflowRun). Rejected —workflow/is owned by the pending, unappliedworkflow-orchestrationcapability; adding schema/migration code to it without a corresponding spec delta would be a de facto modification of another change's owned artifact, the same reasoningworkflow-orchestration-runtime's own D9 already used to justify not writing intostorage/task_metadata.py. A separate,cloud/-owned store composed in prose (ascheduled_tasksrow stores atask_id/workflow_run_idstring reference once dispatched, no foreign key) is fully additive.
D8: platform-sdk's REST API is versioned via a URL prefix (/v1/...), with a single pluggable AuthProvider hook (no-op default), not a full auth system
cloud/sdk/api.py mounts all routes under /v1/ (e.g., POST /v1/tasks, GET /v1/tasks/{id}, GET /v1/devices, GET /v1/hosts, GET /v1/plugins, POST /v1/plugins). Every route accepts an optional AuthProvider.authenticate(request) -> Principal | None hook (default: a NullAuthProvider that always returns an anonymous Principal, i.e., no enforcement) so a later change can add real API-key/OAuth checking without changing route signatures.
- Alternative considered: Ship
platform-sdkwith no versioning prefix at all (bare/tasks,/devices, ...), deferring versioning until a breaking change is actually needed. Rejected — this is explicitly a public-facing surface for external integrators (unlikeconsole-status-api, which is first-party/trusted-network only perweb-console's own scope); starting unversioned and retrofitting a prefix later would break every existing integrator's URLs on day one of the first breaking change, which a public SDK should not do. - Alternative considered: Build real API-key authentication now, since "public-facing" implies untrusted callers. Rejected for this change per its explicit Non-Goal — auth/authz is a distinct, non-trivial concern (key storage, rotation, revocation) that deserves its own change; shipping a no-op hook that is clearly the extension point (mirroring the Driver Registry/
ConditionEvaluator/AssignmentStrategypattern used everywhere else in this design) is enough to make "add real auth later" a localized change, not a rewrite.
D9: cloud/sdk/client.py is a thin requests/httpx-based wrapper, not a code-generated client
The Python SDK client (CloudClient) is hand-written, wrapping the same /v1/... routes cloud/sdk/api.py exposes, using httpx (already a dev dependency, promoted to a runtime dependency here since the client ships as installable code, not just test tooling).
- Alternative considered: Generate the client from an OpenAPI spec (FastAPI already produces one) via
openapi-python-clientor similar. Rejected for this change — adds a codegen build step and a new dev-tool dependency for a surface with only ~6 routes; hand-writing a thin wrapper is faster to ship and easier to read/review, and nothing here forecloses generating a client later once the API surface is larger and more churn-prone.
Risks / Trade-offs
- [Risk] Push-based host sync (D2) means
DevicePool's view of a host's devices is only as fresh as that host's last sync call; a device could gobusy→idleon its host moments after a sync and the pool won't know until the next sync interval → Mitigation:config.sync_interval_secondsis deliberately short-default and documented as eventually-consistent, not real-time;TaskScheduler.assign()re-checksDevicePoolstate immediately before dispatch (not just at submission time) to shrink the staleness window for the assignment decision itself, though a race between "assign" and "actually dispatch" is still possible and is accepted, not solved, in this change. - [Risk]
TaskDispatcher's local-only execution (D5) meansTaskSchedulercan select aPooledDeviceowned by a remote host, butdispatch()will then raiseRemoteDispatchNotSupportedErrorrather than executing it → Mitigation:AssignmentStrategy's default (fifo_match) is configured, in this change's reference wiring, to only considerPooledDevices whosehost_idequals the dispatcher's own host id, so the common single-dispatcher-process deployment never hits the error path; multi-host dispatch is explicitly flagged in Open Questions as unimplemented, not silently broken. - [Risk]
plugin-system's driver-plugin registration path (D6) depends ondriver/registry.pyexposing aregister_driver_type()function that does not exist yet in the pendingdriver-registrycapability's own current spec (only a dict + a lookup function) → Mitigation: documented explicitly as a cross-change dependency in D6 and Open Questions rather than silently assumed;PluginRegistry.register()fordriver-kind manifests fails loudly (raises, does not silently no-op) ifdriver_registry.register_driver_typeis not importable, so the gap surfaces as an explicit error at plugin-load time, not a silent failure to actually register the driver. - [Risk] Accepting
tool/skill-kind plugin manifests in the schema (D6) without wiring them anywhere could mislead a plugin author into thinking registering a tool/skill plugin has an effect → Mitigation:PluginRegistry.register()fortool/skill-kind manifests stores the manifest (so it is listable viaplatform-sdk'sGET /v1/plugins) but returns awired: falsefield in its result and logs a clear "accepted, not yet wired to any execution path" message, rather than pretending success. - [Trade-off] No real auth on
platform-sdk(D8) means anything reachable at the API's network address can submit tasks/query status today → Acceptable for this change since it is not defining deployment/network topology (Non-Goals) and theAuthProviderhook makes adding real enforcement a scoped follow-up, not a redesign. - [Trade-off] A fourth independent SQLite file (
cloud/cloud.sqlite3, alongsidetasks/tasks.sqlite3,workflows/workflows.sqlite3, and any skill-store databases) means fleet-level state, workflow state, and task-memory state are three separate stores with only string-id cross-references, no foreign-key enforcement → Acceptable; this mirrors the same trade-offworkflow-orchestration-runtime's D9 already accepted for its own store relative totask-memory's, and unifying them is explicitly out of scope for any one milestone to force on another's still-pending schema.
Migration Plan
This is purely additive; no existing module is edited:
- Add the
cloud/package:pool.py(DevicePool,HostRegistration,PooledDevice,sync_host_devices()),scheduler.py(TaskScheduler,ScheduledTask,AssignmentStrategyregistry withfifo_match),dispatch.py(TaskDispatcher,Assignment,RemoteDispatchNotSupportedError),plugins.py(PluginManifest,PluginRegistry, entry-point + manifest-file discovery),store.py(CloudStore, schema creation forcloud/cloud.sqlite3),config.py(heartbeat/staleness/queue/strategy/API-version defaults). - Wire
TaskDispatcher's planned-goal path toruntime.task.TaskRunner(...).run(task)and its workflow path toworkflow.runner.WorkflowRunner(...).run(definition)— import only, zero edits toruntime/orworkflow/. - Wire
PluginRegistry.register()'s driver-kind path todriver_registry.register_driver_type(name, builder)if importable; raise a clear, typed error naming the missing function ifdevice-agent-runtime-foundationhas not yet landed that function when this change is implemented (see D6's Note and Open Questions) — do not stub around it silently. - Add
cloud/sdk/:models.py(Pydantic request/response models for the/v1/...routes),api.py(create_cloud_router(*, pool, scheduler, plugin_registry) -> APIRouter, mountable by whatever process assembles the full FastAPI app, same shape asapi/console.py'screate_console_router),client.py(CloudClient, thinhttpxwrapper). - Add
cloud*topyproject.toml's[tool.setuptools.packages.find].include; promotehttpxfrom[dependency-groups].devto[project].dependencies(needed at runtime bycloud/sdk/client.py). - Add new tests (
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-style): multi-host sync + staleness degrade, FIFO assignment with constraint matching, local dispatch composing a stubTaskRunner, remote-assignment raisingRemoteDispatchNotSupportedError, plugin manifest validation + driver registration (with a fakedriver_registry.register_driver_type), and REST route round-trips using FastAPI'sTestClient. - Run
pytest— the full existing suite stays green with zero edits to existing test files, confirming this change touches no existing behavior. - Rollback: net-new package plus a net-new SQLite file with its own schema; reverting is
git revertof the commit(s), with no data migration of any existing store and no external system involved beyond whatever device the composedTaskRunner/WorkflowRunnercalls already target.
Open Questions
- How real cross-host task dispatch should eventually work once
RemoteDispatchNotSupportedErrorneeds to actually go away — a lightweight per-host agent process that pollsCloudStorefor assignments targeting its ownhost_idand callsTaskDispatcherlocally, versus a real RPC/HTTP call from the scheduler's process to a remote host's own dispatcher — left unresolved here since it requires the "no infra" Non-Goal to be revisited first. - Whether
driver/registry.py'sregister_driver_type()function (assumed by D6's driver-plugin registration path) should be proposed as an addition todevice-agent-runtime-foundationbefore or alongside this change's implementation, givenopenspec/specs/is empty and that change is itself still unapplied — flagged for whoever implementsplugin-systemto resolve with that change's owner rather than silently diverging. - Whether
tool/skill-kind plugin manifests should get a real wiring path in a follow-up change oncetools/and the skill stores (skill-catalog-subscription,skill-learning-runtime) gain their own public registration hooks — deliberately left declared-but-unwired here (D6, mirrorsskill-learning-runtime's own precedent of leaving skill execution unbuilt untilworkflow-orchestration-runtimeneeded it). - Whether
ScheduledTask'sconstraintsshould eventually accept askill_id/workflow_definition_iddirectly (submit "run this workflow on any matching device") versus only a baregoalstring — this change'sTaskDispatchersupports both today by inspecting which field is populated, but the scheduler's own matching logic (constraints → candidate devices) does not yet special-case workflow-shaped submissions differently from goal-shaped ones; worth revisiting once real workflow submissions are exercised throughplatform-sdk. - Whether
platform-sdk'sAuthProviderhook should be promoted to a real auth capability in its own right (its own openspec change) once a concrete integrator/security requirement exists, rather than staying a documented no-op — intentionally left as a future decision, not prejudged here.