Repository navigation
feat(agent-device): open, commands, heartbeats and close through a gateway #515
Description
Part of #511.
Scope
After this PR, an agent can point the agent-device CLI at a gateway with
agentDevice.enabled and drive a device: open, commands, heartbeats and
close work as they do on a worker. open lands only on a worker whose
agent-device is available. When none is, it is refused at once with the
reason.
devices, uploads, artifact downloads and diagnostics are #516. The task is
just over the size limit and stays whole: allocate, heartbeat, command and
release share the front's lease handling and the relay host link, and none
of them works through a gateway without the others. What the
front answers when the device's worker is disconnected, and after a gateway
restart, is #517. Until #516 merges, a request that names no lease (other
than /health) is refused through a gateway.
In short
The agent-device front runs on the gateway. Each request it hands to a host
names the agent-device lease it is for. On a gateway, the host link finds
that lease's worker and relays the request there (#514). The worker hands
it to its agent-device host, which registers as the relay target
agent-device. A relayed request's agent-device tenant on the worker is
gw:<gateway instance id>:<principal>, so an agent on the gateway never
shares agent-device sessions with an agent on the worker's own tokens.
sequenceDiagram
participant CLI as agent-device CLI
participant F as front (gateway, new)
participant GL as relay host link (new)
participant W as worker: relay target agent-device (new)
CLI->>F: allocate
F->>F: lease.request requires agent-device (#513)
F->>GL: forward(allocate, lease L)
GL->>GL: lease index: L is on W (remembered)
GL->>W: relay (#514), tenant gw:<id>:<principal>
W-->>CLI: agent-device's reply
CLI->>F: command / heartbeat / release (lease L)
F->>GL: forward(…, lease L)
GL->>W: relay
alt no worker has agent-device available
F-->>CLI: agent-device is not available on this simlock: no worker connected to this gateway can serve agent-device
end
# before, on a gateway
$ agent-device open MyApp --platform ios
Remote daemon is unavailable
# after, with agentDevice.enabled on the gateway
$ agent-device open MyApp --platform ios
(opened on an iPhone leased from a capable worker)
$ agent-device snapshot
$ agent-device close
| Gateway setting / fleet | /v1/agent-device/health |
open --platform ios |
|---|---|---|
agentDevice.enabled unset or false |
404, as today |
404, as today |
| enabled, a capable worker with iOS | 200, the proxy's health | opened on that worker |
| enabled, no capable worker | 200, notAvailable reply naming no worker connected to this gateway can serve agent-device |
that notAvailable reply |
| enabled, capable workers have Android only | 200 | refused: ios is not available on this simlock |
| enabled, a capable worker, but only an incapable one knows the model | 200 | refused by simlock: No worker connected to this gateway can serve agent-device for this request |
A capable worker here: connected, not drained, its view lists
agent-device as available (#519), and the gateway has read its catalog
and its lease.maxTtlMs. A drained worker keeps serving the leases it
holds, but takes no new one.
If this goes wrong, an agent through a gateway gets a device it cannot
drive, commands reach the wrong worker, or a lease outlives its agent.
Technical spec
Modules touched
flowchart LR
M["daemon: main.ts gateway + worker composition *"] --> IDX["agent-device: index.ts *"]
M --> CFG["core: config.ts *"]
IDX --> F["front: front, context, allocate, heartbeat, release *"]
F --> HL["front: host-link.ts *"]
RL["front: relay host link (new)"] -.->|implements| HL
RL --> P["fleet port (built in main.ts): relay, lease lookup, capable workers"]
P --> GR["gateway: GatewayRelay (#514), lease index, worker registry"]
M --> RT["worker: relayTargets option (#514)"]
RT --> H["host: AgentDeviceHost.forward"]
(* = changed, new = new file.)
- Config (
src/core/config.ts). Gateway mode readsagentDevice
(add it toGATEWAY_CONFIG_KEYS).agentDevice.commandstays worker-only:
set on a gateway, it is warned about and ignored, the way
WORKER_ONLY_GATEWAY_KEYShandlesgateway.url.leaseTtlCapMskeeps its
existing check in both modes. - Host-link port (
src/agent-device/front/host-link.ts).status(leaseId?): with a lease id, the status of that lease's host;
without, the status for a new lease.forward(request, identity, leaseId?).InProcessHostLinkignoresleaseIdin both, so worker mode behaves
as today.
- Front (
front.ts,context.ts,allocate.ts,heartbeat.ts,
release.ts).handleRpcreadsstatus()with the call's lease id for a heartbeat,
a release and a command that names one, and with none otherwise.- Every
forwardandexchangepasses the lease id the front already
read: the allocate's chosen id (plan.leaseId), the heartbeat's, the
release's, and the command's (commandLeaseId). allocatesends the host link'sleaseRequireson its
lease.request:["agent-device"]from the relay host link, nothing
fromInProcessHostLink, so a direct worker request is unchanged. A
CAPABILITY_MISSINGanswer takes today's path for a refused
lease.request:refused by simlock: <message>.- After the grant,
allocatereadsstatus(plan.leaseId)and forwards
with thatleaseMaxTtlMs. If that status isunavailable(the worker's
agent-device went down after the grant), it releases the simlock lease
and answers thenotAvailablereply with that reason; no agent-device
lease is made. /healthreadsstatus()with no lease id and, when running, forwards
with no lease id. Any/healthanswer the front makes itself (not
available, or a relay that failed) has status 200, as today's
unavailable answer does, because the CLI hides the reason of a/health
at 500 or above.
- Relay host link (new,
src/agent-device/front/relay-host-link.ts).
It reads a narrow port, built inmain.tsfrom the gateway's parts, so
the edge imports nothing fromsrc/gateway:leaseOf(leaseId): the lease index's worker id and owner, or none.capableWorkers(): each capable worker (above), with its platforms
(from its catalog) and itslease.maxTtlMs.relay(workerId, request, tenant, leaseId?)(feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink #514).- The gateway's instance id, for the tenant prefix.
- It remembers, per lease id, the worker and owner it last read from
leaseOf, and keeps routing to that worker after the index drops the
lease (assimlock release --alldoes), so the worker's agent-device
daemon still answers for it. The fleet port tells the link when the index
removes a lease; the link stamps the entry then, and drops it once the
gateway's ownlease.maxTtlMshas passed since that stamp, checked on
everyforwardand by a sweep every 60 s. - The port's
runningstatus carriesleaseMaxTtlMsonly with a lease
id; the front reads the ceiling fromstatus(leaseId)alone. status()with no lease id:runningwhencapableWorkers()is
non-empty, with the union of their platforms; otherwiseunavailable
with reasonno worker connected to this gateway can serve agent-device.status(leaseId)for a lease it can place (index or memory) and whose
owner is the identity's principal:runningwith that worker's platforms
and ceiling (agentDeviceLeaseMaxTtlMsof itslease.maxTtlMs) when the
worker is connected and listsagent-deviceasavailable(drained or
not), elseunavailablewith reasonthe worker holding this lease cannot serve agent-device. For any other lease, the same asstatus(),
so nothing about another token's lease shows, and the front goes on to
forward, which refuses it.forwardwith a lease id it can place, owned by the identity's
principal: relays to that worker with tenantgw:<gateway instance id>:<principal>. A principal is a token id simlock mints (tok_and a
generated id), so the tenant stays inside feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink #514's 300-character bound; a
tenant over it is refusedrefused: this token cannot be used through a gatewaybefore anything is relayed. A lease it cannot place, or one another principal
owns, answersrefused: lease <id> is not one of yours on this gateway
(status 403) before anything is relayed. A relay that fails
(WORKER_UNREACHABLE,RELAY_TARGET_MISSING) answers the
notAvailablereply naming that error's message; feat(agent-device): a disconnected worker is not an ended lease, and a gateway restart keeps the device #517 refines the
disconnected case.forwardwith no lease id:/healthgoes to any capable worker; any
other request is refused withrefused: this agent-device is too old to use through a gateway; update it.
- Gateway composition (
src/daemon/main.ts,startGatewayDaemon):
withagentDevice.enabled, builds the front over the relay host link and
passes it as the HTTPmounts, withdispatchthe gateway's own,
maxRunMsthe gateway'slease.maxTtlMsand the gateway's
leaseTtlCapMs. On stop, it cancels waiting allocates and disposes the
renewals (cancelWaitingAllocates,disposeAgentDeviceEdge), as the
worker composition does. - Worker composition: with
agentDevice.enabled, registers the relay
targetagent-device(feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink #514'srelayTargets), which calls
AgentDeviceHost.forwardwith identity{ requesterId: tenant, role: "agent" }. When the host's own status isunavailable(the gateway's
view lagged), the target answers the front'snotAvailablereply with
that reason instead of throwing, so the agent gets the reason. The host's
release-scope recording and lease-end participant then apply to relayed
leases as to direct ones.
Contract and event changes
None of its own: it uses #513's requires and #514's relay. No events.
Other code on the same state
The state is the simlock leases the front makes, its front map, and the
agent-device tenants on the worker.
- Gateway lease coordinator (
fleet-coordinator.ts): grants, renews,
releases and cancels the front's leases through the gateway's dispatcher,
as for an HTTP client. The front'srequesterIdis the lease id, so the
worker seesgw:<gateway>:<lease id>, as for every gateway lease. - Gateway lease index (
lease-index.ts): the relay host link reads it
and never writes it. When the index drops a lease, the link's memory still
routes it: the heartbeat reaches agent-device, which answers the lease
gone (the worker's lease-end participant released it), and the front's
existing path (leaseGoneReason, thenreleaseOurs, whose simlock release
finds the lease unknown) answersLEASE_ENDED_BY_HOST. FleetLeaseCoordinator.dispose: rejects every waiting request with
DAEMON_STOPPINGwhen the gateway stops. The edge's
cancelWaitingAllocatesruns first, so the front's own waiting requests
endcancelled.- Worker host lease-end participant (
host/lease-end.ts): ends the
agent-device lease before simlock wipes the device, for relayed leases
too, since it reads the host's own record. - The worker's own front: still serves agents that connect to the
worker directly, under their own principal as tenant. A relayed tenant
always startsgw:, which no worker token's principal does, so the two
never meet in agent-device.
Rules in play
architecture.md: the edge imports nothing from the gateway; it gets a
narrow port. The front reads lease ids in one place. Every exit of
allocate leaves one named state.safety.md: a lease id from the wire routes only after its owner is
checked against the token's principal.testing.md: every table row is a test.
Tests
Seam: real gateway and worker daemons in one process over a real uplink,
HTTP into the gateway's /v1/agent-device, FakeAgentDevice as each
worker's agent-device daemon; modelled on src/agent-device/edge.test.ts
and src/daemon/gateway-fleet.e2e.test.ts.
- with
agentDevice.enabledunset on the gateway,/v1/agent-device/healthanswers 404, as today - with
agentDevice.commandset on a gateway, the gateway logs that it ignores it and starts; withagentDevice.leaseTtlCapMsabovelease.maxTtlMson a gateway, the gateway fails to start, as a worker does - with
agentDevice.enabledunset on the gateway,/rpc,upload/preflight,artifacts/<id>and a diagnostics URL each answer 404 /healthanswers the proxy's health when a worker is capable, and thenotAvailablereply namingno worker connected to this gateway can serve agent-device, with status 200, when none is; when the relay to the capable worker fails, it answers thenotAvailablereply with status 200- with the only capable worker drained, allocate answers the
notAvailablereply, while a command on a lease it already holds still works - a worker whose view says
availablebut whose catalog was not read yet does not count as capable - allocate through the gateway makes a simlock lease on the capable worker, owned by the token's principal, whose id and gateway requester are the CLI's lease id, and its
lease.requestcarriesrequires: ["agent-device"] - allocate on a worker, directly, sends no
requires, as today - with two workers, one capable, every allocate over 5 tries lands on the capable one
- the allocate the worker's agent-device daemon receives names the granted device, carries
ttlMsequal to that worker's ceiling, and has tenantgw:<gateway instance id>:<principal> - allocate with no capable worker answers the
notAvailablereply, and nothing is leased - allocate
--platform ioswhen the capable workers have Android only is refusedrefused: ios is not available on this simlock - allocate when only an incapable worker knows the model answers
refused by simlock: No worker connected to this gateway can serve agent-device for this request, and nothing is leased - allocate whose lease's worker stops being capable between the grant and the forward releases the simlock lease and answers the
notAvailablereply namingthe worker holding this lease cannot serve agent-device - allocate relayed to a worker whose own agent-device went down while the gateway's view still said
availableanswers thenotAvailablereply with the worker's reason, and the simlock lease is released - with every matching device leased, allocate waits and is granted once one is released
- a CLI that hangs up while its allocate waits leaves the gateway's queue, and nothing is granted later
- a command for the lease reaches that worker's agent-device daemon with the prefixed tenant, and its streamed reply reaches the CLI unchanged
- a heartbeat reaches the worker's agent-device daemon first, then renews the simlock lease to now plus its
ttlMs - a heartbeat for a lease whose worker stopped being capable answers the
notAvailablereply namingthe worker holding this lease cannot serve agent-device - while a command runs longer than the TTL, the simlock lease is renewed every TTL/3 through the gateway and does not expire
- release goes to the worker's agent-device daemon first, then releases the simlock lease, and the device is reclaimed
- a release the agent-device daemon fails keeps the simlock lease
- after
simlock release --allon the gateway, the worker's agent-device lease is released before the wipe, and the next heartbeat reaches that worker and answersLEASE_ENDED_BY_HOST - a command with no device selector naming a lease id another token holds, and one the gateway does not know, answer
refused: lease <id> is not one of yours on this gatewaywith status 403, and no worker receives it; with--udid, the same command answersrefused: simlock picks the device, as on a worker - after
simlock release --all, once the fake clock has moved the gateway'slease.maxTtlMsplus 1 s past the index's removal of the lease, the link's memory no longer holds it, and a heartbeat for it answersrefused: lease <id> is not one of yours on this gateway; one second before that, the heartbeat is still relayed - a request with no lease id other than
/healthis refused withrefused: this agent-device is too old to use through a gateway; update it - a relay that fails with
RELAY_TARGET_MISSINGanswers thenotAvailablereply with that message - an agent on a worker token with the same principal name as a gateway agent, connected to the worker directly, does not see the gateway agent's agent-device session, and the gateway agent's commands still work
agentDevice.leaseTtlCapMson the gateway caps the TTL of a lease made through it- stopping the gateway while an allocate waits ends its lease request
cancelled, notDAEMON_STOPPING, and a running command's renewals stop
Assumptions
- Assumption: the tenant a gateway sends agent-device is
gw.<gateway instance id>.<principal>(dots, not colons), because agent-device accepts only 1-128 chars of letters, digits, dot, underscore, hyphen; a character outside letters, digits and hyphen is written_<hex>_and a name over 128 characters is cut and ends in a hash, so every input gives a valid, distinct tenant; simlock's own lease requester staysgw:<id>:<requester>(decided in delivery, pending the maintainer).
Done when
- Every Tests line passes.
docs/HTTP-API.md(/v1/agent-device/*) says a gateway with
agentDevice.enabledserves it, anddocs/CONFIGURATION.mdmarks
agentDevice.enabledandagentDevice.leaseTtlCapMsas read on both
sides andagentDevice.commandas worker side.
Out of scope
devices, and uploads, artifacts and diagnostics by lease header (feat(agent-device): devices, uploads, artifacts and diagnostics through a gateway #516).- A disconnected worker, and a gateway restart (feat(agent-device): a disconnected worker is not an ended lease, and a gateway restart keeps the device #517).
Depends on
- feat(fleet): a lease request may require a capability #513
- feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink #514
Approval
- Approved for delivery
Written by an agent.
Activity
[-]feat(fleet): a gateway relays an HTTP exchange to a worker module over the uplink[/-][+]feat(agent-device): open, commands, heartbeats and close through a gateway[/+]feat(agent-device): the front serves agents through a gateway (#515)
feat(agent-device): gateway front docs, link tests and harness fixes (#…
chore: fallow findings for the gateway front (#515)
test: a gateway front's capable worker is connected, and a bare GET o…
fix: a failed relay on lease calls answers the notAvailable reply; st…
test: a release relay that fails inside a heartbeat answers the notAv…
fix: the tenant a gateway sends agent-device uses dots and is valid f…
chore: relayTenant stays private (#515)
feat(agent-device): open, commands, heartbeats and close through a ga…
test(e2e): agent-device through a gateway on real devices (#538)