Repository navigation
Heartbeats keep the simlock lease alive #473
Description
Part of #466.
Scope
After this PR, an agent keeps its device while it sends commands. Each heartbeat the agent-device CLI sends renews the simlock lease, and a long command keeps it renewed until it ends. An agent that stops sending heartbeats loses the device one TTL later, 5 minutes with today's CLI. When a lease is gone for a reason the agent did not cause (simlock ended it, the agent-device daemon restarted, or the simlock daemon restarted), the agent's next command fails with lease ended by simlock; run disconnect, then open instead of quietly leasing a new device. A lease the agent ended through agent-device (close or disconnect, which release it through the front) answers "lease not found", so close then open still works. A lease ended any other way, the holder's own simlock release <id> and simlock nuke included, counts as ended by simlock. The agent's release of a lease simlock already ended answers released. After a simlock daemon restart, the agent's release also frees its old simlock lease. An operator can cap the TTL of these leases with agentDevice.leaseTtlCapMs, which the end-to-end suite uses to make expiry take seconds.
In short
The CLI sends a heartbeat (agent_device.lease.heartbeat) before every command except devices. The front sends it to agent-device first. If agent-device answers, the front renews the simlock lease, unless the lease already runs longer (the agent renewed it itself). If the lease is gone, the front answers by who ended it: the agent itself (a release through the front, remembered for one TTL), or anything else. While a command for a lease runs, the front renews the simlock lease every third of its TTL, and once more when the command ends, because agent-device keeps its own lease alive during a command and simlock would otherwise expire it mid-command.
sequenceDiagram
actor A as agent-device CLI
participant F as front
participant D as agent-device daemon (via host)
participant L as simlock leasing
A->>F: heartbeat(id, ttlMs)
F->>D: heartbeat(id, ttlMs = ceiling) first
alt agent-device answers the lease
F->>L: lease.renew(id, TTL), only if that moves the deadline later (new)
alt renewed or already later
F-->>A: agent-device's answer
else simlock lease gone
F-->>A: LEASE_ENDED_BY_HOST (new)
end
else lease gone in agent-device
alt the agent released it within one TTL
F-->>A: LEASE_NOT_FOUND, as agent-device sent it (new)
else anything else
F->>L: lease.release(id) as the token's principal (new)
F-->>A: LEASE_ENDED_BY_HOST (new)
end
end
What the agent sees:
# before (#472): heartbeats do not renew the simlock lease
$ agent-device click @e3 # 6 minutes after open, a command every minute
(the simlock lease expired at 5 minutes and simlock wiped the device under the session)
# after
$ agent-device click @e3 # keeps working while heartbeats arrive
$ simlock release --all --yes # operator, on the worker
$ agent-device click @e3
error: lease ended by simlock; run disconnect, then open
$ agent-device close # the agent's own close and release
$ agent-device open MyApp --platform ios # allocates a new lease
| Heartbeat finds | Front answers | simlock lease |
|---|---|---|
| agent-device lease alive, simlock lease alive | agent-device's answer | renewed to now + TTL, unless its deadline is already later |
agent-device lease alive, simlock lease gone (release --all, expiry, device lost) |
LEASE_ENDED_BY_HOST |
none to touch |
| agent-device lease alive, simlock refuses the renew for another reason | simlock's error | not renewed |
| agent-device lease gone, the agent released it through the front within one TTL | agent-device's LEASE_NOT_FOUND |
already released |
| agent-device lease gone, any other cause (simlock ended it, agent-device restart, simlock restart) | LEASE_ENDED_BY_HOST |
released by ID as the token's principal |
| agent-device unreachable or any other error | that error | not renewed |
| no map entry, agent-device still holds the lease (as after a simlock daemon restart) | LEASE_ENDED_BY_HOST |
agent-device lease released first, with the heartbeat's own scope, then the simlock lease |
| a lease ID another token holds | LEASE_ENDED_BY_HOST |
the other token's lease untouched (only agent tokens reach the front, #472) |
a ttlMs that is not a positive finite number |
refused: ttlMs must be a positive number of milliseconds |
not renewed |
How the TTL is chosen (cap is agentDevice.leaseTtlCapMs):
CLI sends ttlMs |
cap | simlock lease TTL |
|---|---|---|
| 300000 | unset | 300000 |
| 300000 | 10000 | 10000 |
| 300000 | 600000 | 300000 |
| none | 10000 | 10000 |
| none | unset | lease.defaultTtlMs (the allocate sends none; a heartbeat keeps the lease's TTL) |
| 1 | unset | 5000, agent-device's own shortest window |
LEASE_ENDED_BY_HOST is a JSON-RPC error with data: { code: "UNAUTHORIZED", message: "lease ended by simlock; run disconnect, then open", details: { reason: "LEASE_ENDED_BY_HOST" } }. The CLI allocates a new lease only for the reasons LEASE_NOT_FOUND, LEASE_EXPIRED and LEASE_REVOKED (isInactiveLeaseError in agent-device's packages/contracts/src/lease-scope.ts), so for this one it fails the command and prints the message.
If this task goes wrong, a user notices a device wiped under a running command, a lost device that the CLI silently replaced with a clean one, a lease the agent extended with simlock lease renew cut short, or close then open failing.
Technical spec
Modules touched
flowchart LR
FR["agent-device/front: heartbeat, renewals, who ended *"] --> DSP["daemon dispatcher: lease.renew / lease.release / lease.list"]
FR --> FM["front map: TTL, who ended *"]
FR --> HL["host link"]
FR --> SH["agent-device/shared: LEASE_ENDED_BY_HOST *"]
MAIN["daemon/main.ts: cap, edge disposal *"] --> FR
CFG["core/config.ts: agentDevice.leaseTtlCapMs *"] --> MAIN
K["contract/schemas.ts: configSchema *"]
FK["agent-device/testing.ts: FakeAgentDevice lease-gone reasons *"]
src/core/config.ts:agentDevice.leaseTtlCapMs, optional, an integer number of milliseconds from 5000 (agent-device's shortest lease window) tolease.maxTtlMs, unset by default. A value outside that range is refused at load, naming the key, aslease.defaultTtlMsis today.src/contract/schemas.ts:configSchema(:776) carriesagentDevice.leaseTtlCapMsbeside simlock runs the agent-device daemon #470'sagentDevicekeys.src/daemon/main.ts: passes the cap to the edge. InstopAuxiliary(:476) it calls the edge'sdisposebefore it stops the HTTP gateway.src/agent-device/shared/: reads a heartbeat'sleaseIdandttlMswith An agent opens and closes a device through simlock #472's lease-call reader, and a command's lease ID with An agent opens and closes a device through simlock #472's command reader; it adds nothing to either. It builds theLEASE_ENDED_BY_HOSTreply above, and reads agent-device's lease-gone reasons (UNAUTHORIZEDwithdetails.reasonLEASE_NOT_FOUND,LEASE_EXPIREDorLEASE_REVOKED, the same test asisInactiveLeaseError).src/agent-device/front/:- The TTL, by the table above: the CLI's
ttlMslowered to the cap and raised to 5 s; the cap when the CLI sent none; and when neither is set, nottlMsin the allocate and the granted lease's own TTL after it. An agent opens and closes a device through simlock #472's allocate sends this TTL inlease.request. The front map stores it per lease. A heartbeat that carriesttlMsreplaces the stored TTL by the same rule, but only once simlock accepted a renewal with it (or the never-shorten rule skipped one); a heartbeat whose renewal simlock refused keeps the old TTL. One that carries none keeps it. AttlMsthat is not a positive finite number is refused with An agent opens and closes a device through simlock #472's message before anything is sent. - The ceiling. Every heartbeat the front forwards carries
ttlMsequal to An agent opens and closes a device through simlock #472's ceiling (HostLink.status().leaseMaxTtlMs), as An agent opens and closes a device through simlock #472's allocate does, so the agent-device lease never expires before the simlock lease. - Renewing never shortens. Before each renewal the front dispatches
lease.listwith the entry's identity and reads the lease'sttlDeadline. It dispatcheslease.renewwith the stored TTL only when now + TTL is later than that deadline. A lease the list does not show counts asUNKNOWN_LEASE. - Heartbeat. The front sends it through the host link first. On a success reply (An agent opens and closes a device through simlock #472's shared rule) it renews as above, as the map entry's identity. A renewal or list that answers
UNKNOWN_LEASEanswersLEASE_ENDED_BY_HOST. Any other error from simlock (for exampleBAD_REQUEST, or a daemon that is stopping) goes back to the CLI as the JSON-RPC errorCOMMAND_FAILEDwith simlock's message, and marks nothing. When agent-device answers a lease-gone reason: an entry ended by the agent passes agent-device's answer through; anything else dispatcheslease.releasefor that ID with the requesting token's session, ignoresUNKNOWN_LEASEandFORBIDDEN(simlock's own ownership check decides), and answersLEASE_ENDED_BY_HOST. Any other error from that release goes back asCOMMAND_FAILEDwith simlock's message and marks nothing, so the next heartbeat tries again. Any other agent-device answer, including an unreachable daemon, goes back unchanged and renews nothing. A heartbeat naming a lease ID the map does not hold for this principal: a lease-gone reason from agent-device takes the "anything else" path above; a success reply means agent-device still holds that lease (as after a simlock daemon restart), so the front first releases it through the host link with the scope the heartbeat itself carries (runId,leaseProvider,clientId,deviceKey), and only on a success reply releases the simlock lease and answersLEASE_ENDED_BY_HOST; an error or no reply goes back unchanged and touches no lease. - Who ended it. An agent opens and closes a device through simlock #472's map entry marks when its lease ended. This task adds who: ended by the agent, or ended by simlock. An agent opens and closes a device through simlock #472's release marks the entry "release in progress" before it dispatches
lease.release, and ended by the agent once that returns. While "release in progress" is set, a renewal or heartbeat that finds the lease gone marks nothing, so the agent's own release always ends as ended by the agent. "Release in progress" is a state, not a mark: when thatlease.releasefails with anything butUNKNOWN_LEASE(An agent opens and closes a device through simlock #472 then keeps the simlock lease), the state is cleared and the entry is not ended, as before the release. The front marks it ended by simlock when a renewal (heartbeat or timer) or its list finds the lease gone, when the "anything else" release above runs, when An agent opens and closes a device through simlock #472's release getsUNKNOWN_LEASE, and when An agent opens and closes a device through simlock #472's allocate check finds the lease unlisted. (So afterrelease --all, the agent's own release getsUNKNOWN_LEASEand marks it ended by simlock; the CLI'scloseclears its stored lease, so its nextopenallocates without a heartbeat for the old ID.) A mark, once set, never changes. One function infront/decides who ended a lease; the heartbeat, the release, the renewal timer and An agent opens and closes a device through simlock #472's allocate check call it. Nothing outside the front writes a mark. - A heartbeat for an entry ended by simlock answers
LEASE_ENDED_BY_HOSTat once. Nothing goes to agent-device or to simlock. - A release for an entry ended by simlock. The front sends the release to agent-device and answers its reply (
releasedtrue or false). It dispatches nolease.release: the simlock lease is already gone. - A release naming a lease ID the map does not hold for this principal (as after a simlock daemon restart, when the map is empty). This replaces An agent opens and closes a device through simlock #472's "agent-device only" rule. The front sends the release to agent-device first. On a success reply (
releasedtrue or false) it dispatcheslease.releasefor that ID with the requesting token's session, and answers agent-device's reply. simlock's own owner check refuses another principal's lease; the front ignores thatFORBIDDENandUNKNOWN_LEASE, which counts as released. On anything else from agent-device it touches no simlock lease and returns agent-device's reply. - Renewals while a command runs. A command (An agent opens and closes a device through simlock #472's classifier) whose lease ID the map holds for this principal, in an entry not ended, counts as running from when the front forwards it until its response body ends, errors, or the client hangs up. A command naming an ended entry's lease starts no renewals. A call counts as running for at most
lease.maxTtlMs(4 hours by default); after that it renews nothing more, and the lease lives on heartbeats. While a lease has one or more running calls, aClocktimer renews it every TTL/3 (at least 1.67 s, since the TTL is at least 5 s), by the never-shorten rule above. When the stored TTL changes, the timer is re-armed at the new TTL/3 from that moment. When the last call ends, the front cancels the timer and renews once more. A renewal that finds the lease gone (UNKNOWN_LEASE) cancels the timer; any other renewal error logs a warning and keeps the timer, so the next tick tries again. The call's own reply still goes back. A release through the front, anddispose, cancel the timer too. dispose()cancels every renewal timer. From then on the front arms no timer and sends no renewal, final ones included. Everything else about the request path stays as An agent opens and closes a device through simlock #472 left it.
- The TTL, by the table above: the CLI's
src/agent-device/testing.ts:FakeAgentDevicecan answer a heartbeat with each lease-gone reason, and records each heartbeat'sttlMs.docs/CONFIGURATION.md: the new key.docs/HTTP-API.md: the heartbeat answers and the message.
Assumption: an ended entry, either mark, is remembered for one TTL of that lease after it ended (#472's pruning). The notes say "for a while"; one TTL covers a CLI that heartbeats the old ID right after close. After that, a heartbeat takes the "anything else" path and a release the no-entry path. Their answers stay the same, with one exception: a heartbeat for an agent-ended lease then answers LEASE_ENDED_BY_HOST.
Contract and event changes
configSchemagainsagentDevice.leaseTtlCapMs, optional. Protocol +1 at merge.- No event changes. Each renewal emits
lease.renewed, and each releaselease.releasedwith reasonexplicit, as for any client.
Other code on the same state
The state: each front lease's deadline and expiry timer, the front map, the renewal timers, and the agent-device daemon's leases.
LeaseLifecycle#renew(src/leasing/lease-lifecycle.ts:128-145) sets the deadline to now + the TTL it is given and stores that TTL. The agent's ownPOST /v1/leases/:id/renewor socketlease.renewreaches it too. A longer renewal by the agent wins: the front skips its renewal while the deadline is later than now + TTL. A shorter one by the agent also stands until the front's next renewal; if that deadline passes first, the lease expires as the agent asked, and simlock ending a simlock lease ends the agent-device session first #474 makes the wipe wait for a running command. When the agent renews between the front'slease.listand itslease.renew, the later renewal sets the deadline; the window is the gap between two in-process dispatches.- The expiry timer,
release --all,simlock nuke(releaseAllDuringMaintenance), a lost device, startup reconcile, and a directlease.release(the holder's ownsimlock release <id>) end the simlock lease; simlock wins. A renewal timer that fires after that finds the lease gone, marks the entry ended by simlock and cancels itself. The next heartbeat answersLEASE_ENDED_BY_HOST. The agent-device session stays until simlock ending a simlock lease ends the agent-device session first #474's participant ends it, or until the agent runsdisconnect, whose release answers as above. - simlock ending a simlock lease ends the agent-device session first #474's participant releases the agent-device lease when simlock ends a lease. It writes nothing in the front map. The next heartbeat gets a lease-gone reason from agent-device, or the front's renewal finds the simlock lease gone; either marks the entry ended by simlock here.
- Startup reconcile after a simlock daemon restart (ADR 0019) ends leases whose device is not running and restores the expiry timers of the rest. The front map is empty after a restart, so the first heartbeat for a surviving lease releases it; a lease nobody heartbeats expires at its deadline.
- simlock runs the agent-device daemon #470's supervisor restarts a dead agent-device daemon, and An agent opens and closes a device through simlock #472's host builds a new proxy for it. The restarted daemon has no leases, so each agent's next heartbeat gets a lease-gone reason and takes the "anything else" path.
- The agent-device daemon renews its own lease on each heartbeat with the
ttlMsthe front sends (the ceiling), and keeps it alive while a command runs (agent-device'sheartbeatLeaseand protected leases insrc/daemon/lease-registry.ts). So it outlives the simlock lease. - Daemon stop:
stopAuxiliarydisposes the edge, then stops the HTTP gateway, which destroys every socket (src/http/server.ts:203-215). The calls that end then send no final renewal, so no renewal races leasing teardown.
Rules in play
architecture.mdrule 5: renewal is a direct call from the front's request path and its timer, never an event handler.architecture.mdrule 10: one function decides who ended a lease; each wire rule is read through An agent opens and closes a device through simlock #472'sshared/readers.architecture.mdrule 12: every exit of a running call (end, error, hang-up, release, failed renewal, dispose) leaves its lease's renewal timer either armed for a running call or cancelled.safety.mdrule 10: a lease ID from the wire is acted on only through simlock's own ownership checks, and only agent tokens reach the front (An agent opens and closes a device through simlock #472), so no admin bypass applies.testing.mdrule 2: assert on the lease's deadline and on whatFakeAgentDeviceand simlock recorded, not on counts of timers.
Tests
Seam: the whole worker daemon in-process with a FakeClock, a FakeDriver, HTTP on loopback and a FakeAgentDevice, as #472 set up in src/agent-device/edge.test.ts; requests sent with fetch. Model: that file and src/daemon/main.test.ts (:1018-1090). The config key: src/core/config.test.ts.
- a heartbeat reaches the agent-device daemon before simlock, carrying
ttlMsequal to the ceiling, and once it answers, the simlock lease's deadline is now plus the CLI'sttlMs - each row of the TTL table gives the simlock lease, its heartbeat renewal and its renewal interval that TTL (one test per row)
- a heartbeat with a longer
ttlMsthan the allocate's moves the deadline to now plus that TTL, and a following heartbeat with nottlMsrenews with it too - after the agent's own
simlock lease renewwith a longer TTL, a heartbeat and a running command's renewals leave that later deadline in place - a heartbeat the agent-device daemon cannot answer returns that error and leaves the simlock lease's deadline where it was
- with no cap set, a heartbeat whose
ttlMsis abovelease.maxTtlMsanswersCOMMAND_FAILEDwith simlock'sBAD_REQUESTmessage and leaves the deadline where it was, and a following heartbeat with a validttlMsrenews the lease - a heartbeat for a lease the agent released through the front answers
LEASE_NOT_FOUND, and a following allocate gets a new lease - one TTL after the agent's release, a heartbeat for that lease answers
LEASE_ENDED_BY_HOST - after
simlock release --all, after expiry, and after the device is lost, a heartbeat for the lease answersLEASE_ENDED_BY_HOSTwith the message and reason above, and makes no new simlock lease - a heartbeat the agent-device daemon answers
LEASE_NOT_FOUND,LEASE_EXPIREDorLEASE_REVOKED, for a lease simlock holds and the agent did not release, releases the simlock lease and answersLEASE_ENDED_BY_HOST - a heartbeat naming a lease ID the front map lacks, which simlock holds for this token's principal: when agent-device answers a lease-gone reason, the simlock lease is released; when agent-device still holds the lease, it is released with the heartbeat's scope first, then the simlock lease; when agent-device is unreachable, neither lease is touched; each answers as in the table
- a heartbeat from a second token naming the first token's lease ID answers
LEASE_ENDED_BY_HOST, and the first token's lease is neither released nor renewed - after a heartbeat answered
LEASE_ENDED_BY_HOSTbecause simlock ended the lease, the next heartbeat answers it again and the agent-device daemon receives nothing for it - a lease released through the front while a command runs stays ended by the agent: a renewal that fires between the simlock release and the end of the agent's release, and the command's final renewal, mark nothing, and the next heartbeat still answers
LEASE_NOT_FOUND - after
simlock release --all, the agent's release answers success, a heartbeat for the old ID then answersLEASE_ENDED_BY_HOST(ended by simlock, notLEASE_NOT_FOUND), and the next allocate gets a new lease; an entry found unlisted by another allocate's check answers its next heartbeatLEASE_ENDED_BY_HOSTwithout reaching the agent-device daemon - a release for a lease simlock ended answers the agent-device daemon's reply as a success, and the agent-device lease is released
- while a command for the lease runs longer than its TTL, the simlock lease is renewed every TTL/3 and does not expire
- when the command ends, the lease is renewed once more, and its deadline does not move again over the next TTL
- a command that fails, and one whose CLI hangs up, stop the renewals the same way
- with two overlapping commands on one lease, renewals go on until the second one ends
- a renewal that fails because simlock ended the lease mid-command stops the renewals, and the command's reply still reaches the CLI; a renewal refused for another reason (the test makes the daemon's
lease.renewdispatch reject once withINTERNALthrough a wrapped dispatch) is tried again at the next tick - an agent release whose
lease.releasesimlock refuses with another error clears "release in progress": the simlock lease stays, and a later renewal that finds it gone marks it ended by simlock - a heartbeat lowering the TTL from 300 s to 5 s while a command runs re-arms the renewals, and the lease does not expire before the command ends
- a command naming a lease ID the front map does not hold for its token, or the lease of an ended entry, starts no renewals
- a release through the front while a command runs stops that lease's renewals
- a lease whose CLI stops sending heartbeats expires one TTL after its last heartbeat, and its device can be leased again
- after the agent-device daemon restarts, the next heartbeat answers
LEASE_ENDED_BY_HOSTand releases the simlock lease (the proxy's 409Proxy instance changedfor a request stamped by the old proxy is the proxy's own behaviour, which the CLI retries; not tested here) - after a simlock daemon restart on the same data directory and the same
FakeDriver, so the device keeps running and the test first sees the lease still listed, a heartbeat for a lease made before the restart answersLEASE_ENDED_BY_HOSTand releases that simlock lease - after the same restart (same
FakeDriver, lease still listed), a release for a lease made before the restart answers the agent-device daemon's reply as a success and releases that simlock lease - a release with no map entry from a second token, naming the first token's lease ID, answers the agent-device daemon's reply and leaves the first token's simlock lease in place
- stopping the daemon while a command runs renews nothing once the stop began: the lease's deadline, read after a restart on the same data directory and the same
FakeDriver(the lease still listed), is the one it had before the stop - (config)
agentDevice.leaseTtlCapMsis unset by default, accepts 5000 andlease.maxTtlMs, and refuses 4999, a fraction and a value abovelease.maxTtlMs, naming the key - (config)
config.getreturnsagentDevice.leaseTtlCapMswhen it is set
Done when
- Every line under Tests is a test, and each fails when the behaviour it names is removed.
- A heartbeat after
simlock release --allanswerslease ended by simlock; run disconnect, then open, and a heartbeat after the agent's own release answersLEASE_NOT_FOUND. - A command longer than the lease's TTL keeps its simlock lease, shown with a fake clock.
docs/CONFIGURATION.mdlistsagentDevice.leaseTtlCapMs, anddocs/HTTP-API.mdthe heartbeat answers.- No line needs real hardware: End-to-end tests: agent-device through a simlock worker on real iOS and Android devices #477 runs the expiry, agent-device restart and simlock restart scenarios on iOS and Android.
Out of scope
- Ending the agent-device session when simlock ends a lease (simlock ending a simlock lease ends the agent-device session first #474). Until then a session simlock ended stays until the agent's
disconnect. - Releasing simlock leases whose agents never heartbeat again after a restart; they expire at their TTL.
- Restarting the agent-device daemon (simlock runs the agent-device daemon #470), and the host's new proxy after a restart (An agent opens and closes a device through simlock #472).
Depends on
Approval
- Approved for delivery
Written by an agent.
Activity
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the spec check (renewals never shorten, TTL table with cap, floor and default, heartbeat ceiling, other renew and release errors, final renewals skipped at stop, front learns ended-by-simlock itself, missing tests).
Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the second spec check (no-entry heartbeat never frees a device agent-device holds, release-in-progress mark, TTL stored only once accepted, timer re-arm and retry, running bound, restart tests keep the device).
Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated: closure-check findings fixed (maintainer asked to fix all).
Written by an agent.
github-actions commentedon Oct 13, 2026
Now
task:ready: approved, technical spec present, #472 closed.feat(agent-device): heartbeats and running commands keep the simlock …
docs(agent-device): heartbeats renew the simlock lease; agentDevice.l…
refactor(agent-device): route front calls in one function (#473)
fix(agent-device): protocol 31 -> 32, and two heartbeat tests that pr…
fix(agent-device): a release after release --all marks the entry ende…
fix(agent-device): clamp renewal timer delays to the setTimeout range…