Repository navigation
simlock ending a simlock lease ends the agent-device session first #474
Description
Part of #466.
Scope
After this PR, when simlock ends a simlock lease that an agent got through
agent-device (simlock release --all, expiry, device lost, an operator's
release), the agent-device session on that device ends before simlock wipes
it. A command still running there finishes first, up to lease.endWaitMs.
The agent's next command fails with "lease ended by simlock", and the next
agent gets a clean device that agent-device does not still hold.
Before this PR, simlock ends the simlock lease and wipes at once. The
agent-device lease and its agent-device session stay behind in the
agent-device daemon, so the device claim stays, and the next agent's open
on that device fails with DEVICE_IN_USE.
In short
simlock already knows how to wait before a wipe (#469: the
LeaseEndParticipant port, with the lease.endWaitMs ceiling). This task
plugs the host into it. The host releases the agent-device lease with the
same scope the agent's own release would carry. agent-device then waits for
the running command, ends the agent-device session and clears its device
claim before it answers (agent-device PR #3480). Only then does the wipe
start.
New term:
- release scope: the fields agent-device checks before it releases a
lease: tenant, run ID, and the lease provider, client ID and device key
the lease was allocated with. Each but the device key is 1 to 128
characters of letters, digits,.,_and-, as agent-device
requires; the device key is any string of 1 to 128 characters (agent-device
names devices likeios:mobile:<id>). Example: tenant
tok_ci1, run IDproxy-3f2a…, lease providerproxy. A release that
lacks one of them is refused withLEASE_SCOPE_REQUIRED; one that names a
different value, withLEASE_SCOPE_MISMATCH.
sequenceDiagram
actor Op as Operator
participant L as simlock leasing
participant H as host (edge)
participant D as agent-device daemon
participant Dev as Device
Op->>L: simlock release --all
L->>L: commit: lease gone, device reclaiming
L-->>Op: released
L->>H: lease ended (lease id), signal (new: host participant)
H->>D: lease release, recorded release scope (new)
Note over D: waits for the running command,<br/>ends the agent-device session,<br/>clears the device claim
D-->>H: released
H-->>L: done
L->>Dev: shut down and erase
# before: the operator ends everything while agent A runs a command
$ simlock release --all --yes
# the device is erased under A's running command; then agent B:
$ agent-device open MyApp --platform ios
error: DEVICE_IN_USE ...
# after
$ simlock release --all --yes
# A's command finishes, A's agent-device session ends, then the wipe
$ agent-device snapshot # agent A
error: lease ended by simlock; run disconnect, then open
$ agent-device open MyApp --platform ios # agent B, once the wipe is done
# opened on that device
If this goes wrong, a user would see an app erased under a running command,
a device that stays reclaiming for 10 minutes when nothing runs on it, or
the next agent's open failing with DEVICE_IN_USE.
Technical spec
Modules touched
flowchart LR
M["daemon: startDaemon *"] --> LS["leasing: createLeasing (#469)"]
M --> IDX["agent-device: index.ts *"]
LS -.->|LeaseEndParticipant| P["host: lease-end participant (new)"]
IDX --> P
P --> REC["host: release scope record (new)"]
RW["host: recording upstream fetch (new)"] --> REC
PX["host: proxy (#472)"] --> RW
RW --> UP["AgentDeviceUpstream"]
P --> UP
RW --> SH["shared: release scope reader (new), reply success rule *"]
F["testing.ts: FakeAgentDevice *"] -.->|implements| UP
(* = changed, new = new file.)
src/agent-device/shared/:- The release scope reader (new). It reads
tenantId,runId,
leaseProvider,clientIdanddeviceKeyfrom the lease in an
allocate reply.tenantIdandrunIdmust be present. Each field
present must be a string of 1 to 128 characters of[A-Za-z0-9._-],
exceptdeviceKey, which may be any string of 1 to 128 characters.
Anything else fails the check. (Device key rule decided by the maintainer
on 2026-10-13.) - An agent opens and closes a device through simlock #472's rule that decides whether an agent-device reply is a success
gains one condition: an allocate reply is a success only when its
release scope passes the reader. The front and the host both call that
one rule, so they can never disagree on whether an allocate went
through. An allocate reply that fails the check is, for the front, an
allocate that failed after agent-device may have committed. An agent opens and closes a device through simlock #472's path
for that case runs: the front releases the agent-device lease through
the host link, then the simlock lease, and answers aCOMMAND_FAILED
error.
- The release scope reader (new). It reads
src/agent-device/host/gains the lease-end participant and the release
scope record (lease id → release scope). Suggested file:
src/agent-device/host/lease-end.ts.- The record is filled on the host side, by a recording wrapper around the
upstreamFetchthe host gives the proxy (An agent opens and closes a device through simlock #472). The wrapper sees each
request after the proxy set the tenant, and each agent-device reply. It
names the call with An agent opens and closes a device through simlock #472's shared call classification (both spellings)
and its lease ID reader. Nothing insrc/agent-device/front/writes or
reads the record, so a gateway relay adapter for the host link later
needs no recording of its own.- An allocate whose reply is a success: the host records the release
scope from the reply's lease, not from the request. The proxy overwrites
the tenant, so the reply is what agent-device stored. - An allocate: while it is in flight, the host keeps its promise under
the lease id, so the participant can wait for it. - A release: while it is in flight, the host keeps its promise under the
lease id, so the participant can wait for it. A success (released
true or false) drops the entry. An error, a non-JSON body, an
unreachable daemon or an aborted request keeps it. - Once the participant has started on a lease id, it owns that entry: an
allocate or release reply for that id that arrives later writes nothing
to the record. So the record never holds an entry for a lease that has
already ended.
- An allocate whose reply is a success: the host records the release
- The participant, for each lease Leasing calls lease-end participants before it reclaims a device #469 hands it:
- If an allocate for the lease id is in flight, waits for it, or for
Leasing calls lease-end participants before it reclaims a device #469's signal, whichever comes first (simlock can end a lease after the
front's grant and before agent-device answers). A success records the
scope, and the participant goes on with it. Then: returns at once when
the record has no entry for the lease id: the lease was not the
edge's, or the agent already released it. Also returns at once, dropping
the entry, whenAgentDeviceUpstream.current()is notrunning: a
daemon that is starting or restarting lost every lease with its
process. - If a release is in flight for the lease, waits for it, or for Leasing calls lease-end participants before it reclaims a device #469's
signal, whichever comes first. The release succeeded: returns. It
failed or was aborted: goes on to step 3. The signal fired: drops the
entry and returns. - Sends
agent_device.lease.releasewith the full recorded release
scope throughAgentDeviceUpstream.fetch, with Leasing calls lease-end participants before it reclaims a device #469's signal. This
goes straight to the agent-device daemon with its daemon token, not
through the proxy: the proxy'sauthenticateneeds the agent's simlock
token, which the host never holds. It does not use the agent-device
daemon's admin leaseDELETE. - Drops the entry, whatever the outcome. An error from agent-device is
thrown to Leasing calls lease-end participants before it reclaims a device #469, which logs it and reclaims. An abort returns.
A signal already aborted when the call comes (nuke, a call after
graceful stop began) skips steps 2 and 3: the participant drops the entry
and returns.
- If an allocate for the lease id is in flight, waits for it, or for
- The participant writes nothing in the front's map. The front learns that
simlock ended the lease on its own (Heartbeats keep the simlock lease alive #473): agent-device answers "lease
not found" for the next heartbeat, or simlock answersUNKNOWN_LEASEto
the next renew. src/agent-device/index.tsexports whatstartDaemonneeds to register
the participant.src/daemon/main.ts: withagentDevice.enabledtrue,startDaemon
passes the participant inleaseEndParticipantstocreateLeasing. With
it false, it passes none. An agent opens and closes a device through simlock #472 already builds the host before
createLeasing, so the participant is built from it there and nothing
moves. The edge'sdispose(Heartbeats keep the simlock lease alive #473), which runs instopAuxiliarybefore
settle, leaves participant calls alone; only Leasing calls lease-end participants before it reclaims a device #469's signal ends them.src/agent-device/testing.ts:FakeAgentDevicegains what agent-device
does and the tests need:- a device claim: an allocate for a device whose lease is live, or whose
released lease still has a command running, answersDEVICE_IN_USE;
the claim clears when that command ends; - a release checks the release scope as agent-device does: a missing
tenant or run ID, or a missing lease provider, client ID or device key
the lease has, answersUNAUTHORIZEDwith reasonLEASE_SCOPE_REQUIRED;
a different value answers reasonLEASE_SCOPE_MISMATCH; - a release removes the lease at once, and, when the lease has a held
command, answers only after the command is let go, as agent-device does; - a held command can be set to keep running after its HTTP request is
aborted, until the test lets it go; - a release can be made to hang until its signal aborts, or to answer an
error; - the test can alter the run ID and client ID in the allocate reply only;
the lease the fake stores keeps the scope the CLI's allocate carried, so
a release with that scope succeeds, as An agent opens and closes a device through simlock #472's path for a failed check
sends it.
- a device claim: an allocate for a device whose lease is live, or whose
#469's participant call carries an abort signal that fires when the ceiling
passes, on graceful stop (settle) and on maintenance (nuke). The
participant treats every abort the same way.
One request through the hop, with the failure reply:
sequenceDiagram
participant L as simlock leasing (#469)
participant P as host participant
participant U as AgentDeviceUpstream
participant D as agent-device daemon
L->>P: lease ended {leaseId, owner, deviceId, platform, reason}, signal
alt no record for the lease
P-->>L: done at once
else the agent's release in flight
P->>P: wait for it or the signal
Note over P: success: done.<br/>failed or aborted: send its own release (below)
else recorded
P->>U: POST /rpc agent_device.lease.release {leaseId, tenantId, runId, leaseProvider, clientId, deviceKey}
U->>D: forward with the daemon token
alt a command still runs
D->>D: wait for it, end the agent-device session
end
alt ok
D-->>P: {released: true or false}
P-->>L: done
else error or unreachable
D-->>P: JSON-RPC error
P-->>L: throws; #469 logs and reclaims
else #469's signal fires
P->>U: abort the request
P-->>L: done; #469 reclaims
end
end
Facts this relies on, read from the agent-device code (branch
fix/session-ends-with-lease, PR #3480):
lease_releasereleases the registry entry first. It then awaits
endSessionsForLeasebefore it replies (src/daemon/handlers/lease.ts).
That runsendLeaseSessionsunder each session's execution locks
(src/daemon/lease-lifecycle.ts), so a running command finishes first.
The device claim is cleared even when teardown fails. The handler does not
watch its caller hanging up, so a release whose request simlock aborts
still ends the session once the command ends.- A release of an unknown lease answers
{released: false}, not an error
(LeaseRegistry.releaseLease). getLeaserefuses a release without tenant and run ID, or without a
lease provider, client ID or device key the lease has
(assertLeaseOwnerScope,assertLeaseScopeMatchin
src/daemon/lease-registry-scope.ts). The proxy CLI allocates with lease
providerproxy, a client ID and run IDproxy-<clientId>
(src/cli/connection/proxy-profile.ts), so all of them are needed.
Contract and event changes
None. Searched src/contract/: no operation, schema or error code changes.
No event is added or changed; lease.released still fires when the end is
committed.
Other code on the same state
The state: the release scope record, the agent-device daemon's leases,
sessions and device claims, and the device's wait before reclaim.
- Leasing calls lease-end participants before it reclaims a device #469's lease-end runner (in
src/leasing/) calls the participant for
every ending: release, release-all, expiry, device lost, startup. At
startup after a simlock daemon restart, the record is empty, so the
participant returns at once and sends nothing. Leasing calls lease-end participants before it reclaims a device #469's ceiling and stop
signal win over the participant: when they fire, it aborts and returns. - The agent's own release (An agent opens and closes a device through simlock #472's release path, through the host). The
three orders:- It is answered before simlock ends the lease: the entry is gone, the
participant returns at step 1 and sends nothing. Agent wins. - simlock ends the lease while it is in flight: the participant waits for
it (step 2). If it succeeds, the participant sends nothing. If it fails
or the agent hung up, the participant sends its own. Either way one
release succeeds before the wipe. - The participant's release goes first and the agent's arrives meanwhile
or after: Heartbeats keep the simlock lease alive #473 forwards the agent's release, and agent-device answers
the second onereleased: false, which changes nothing. Both end with
the agent-device lease released.
- It is answered before simlock ends the lease: the entry is gone, the
- An agent opens and closes a device through simlock #472's allocate failure paths. A late grant after a hang-up never
reaches agent-device, so it has no entry. A failure after agent-device
may have committed (hang-up, bad reply, failed scope check) makes the
front release the agent-device lease through the host link first. That
release passes the recording wrapper, so a success drops the entry and
the participant sends nothing. If it fails, the simlock lease stays
(An agent opens and closes a device through simlock #472), the entry stays, and the participant releases it when that
simlock lease ends. - Heartbeats keep the simlock lease alive #473's heartbeat and renew loop. After the commit, a heartbeat or a
renew finds the lease gone (agent-device's "lease not found" after the
participant's release, or simlock'sUNKNOWN_LEASEbefore it) and Heartbeats keep the simlock lease alive #473
answersLEASE_ENDED_BY_HOST. Both orders give the agent the same answer. - Heartbeats keep the simlock lease alive #473's agent-device daemon restart. When the supervisor's generation
changes, the host rebuilds the proxy (An agent opens and closes a device through simlock #472). The new daemon knows no old
lease, so the participant's release answersreleased: falseand the
wipe starts at once. The record is not cleared on restart; each entry
goes when its simlock lease ends. - agent-device's own expiry sweep (PR #3480, every second). The front
raises thettlMsit forwards to agent-device to the supervisor's
AGENT_DEVICE_LEASE_MAX_TTL_MS(An agent opens and closes a device through simlock #472, simlock runs the agent-device daemon #470), so the agent-device lease
never ends before the simlock lease. If it ever did, the participant's
release answersreleased: false. - The ceiling. When
lease.endWaitMspasses with a command still held,
Leasing calls lease-end participants before it reclaims a device #469 aborts the signal and reclaims. agent-device has already released
the lease, but keeps its device claim until the command ends. Until then,
a second agent'sopenthat simlock grants that device gets
agent-device'sDEVICE_IN_USE; An agent opens and closes a device through simlock #472's failed-allocate path releases the
simlock lease and passes the error to the CLI. Once the command ends, the
sameopenis granted. LeaseReleaseCoordinator.releaseAllDuringMaintenance(nuke) and
graceful stop: Leasing calls lease-end participants before it reclaims a device #469 hands the participant an aborted signal; it sends
nothing, or aborts what it sent, and returns. On graceful stop the
supervisor stops the agent-device daemon too, which loses its leases.
After a participant release that agent-device answered with an error or
never received, agent-device keeps that lease and its
device claim until the agent runsdisconnect(its next command answers
LEASE_ENDED_BY_HOST, Heartbeats keep the simlock lease alive #473) or the lease's raised TTL passes. Meanwhile
anopenthat simlock grants that device getsDEVICE_IN_USE, as after
the ceiling. Accepted by the maintainer: rare. A simlock restart clears
it: simlock runs the agent-device daemon #470 stops and starts the agent-device daemon, the new daemon has no
leases, and agent-device clears a device claim whose owner process is gone
at its start and atopen(session-open-execution.ts).- The edge's
dispose(Heartbeats keep the simlock lease alive #473) runs instopAuxiliary, beforesettle
aborts Leasing calls lease-end participants before it reclaims a device #469's signals (src/daemon/server.ts,#stop). It does not
touch participant calls. The HTTP gateway stopping there aborts the
agent's forwarded requests, but not the participant's, which go through
AgentDeviceUpstreamdirectly.
Rules in play
architecture.mdrule 5: the wait is a direct call through the port,
never alease.releasedhandler.- Rule 10: one rule decides whether an allocate reply is a success, called
by both the front and the host. - Rule 11: the release to agent-device crosses a process, and so does the
wait for the agent's own release. Both are bounded by Leasing calls lease-end participants before it reclaims a device #469's signal; the
participant adds no timer of its own. - Rule 12: every exit of the participant drops the record entry, and leaves
no request open after Leasing calls lease-end participants before it reclaims a device #469's signal. - Rules 14 and 15: leasing holds only the neutral port; nothing in
src/leasing/orsrc/core/imports the edge. The host fills the record
from the host side, so the front stays the same for a relay adapter. safety.mdrule 10: the release scope comes from agent-device's own
allocate reply, and is checked before it is stored.testing.mdrule 2: a test that the wipe waits must first show the wipe
is held, then let the command go and show it starts.
Tests
Seam: the whole daemon in-process (startDaemon with a FakeDriver and a
FakeClock), the agent's calls over HTTP with fetch to
/v1/agent-device/*, FakeAgentDevice behind AgentDeviceUpstream through
StartDaemonOptions, and operator calls (lease.release-all,
lease.release) over the socket as admin. Model: #472's
src/agent-device/edge.test.ts and src/daemon/main.test.ts
(:1018-1090). Tests go in src/agent-device/edge.test.ts, except the
lines marked (host), which drive the recording wrapper and the participant
directly in src/agent-device/host/lease-end.test.ts (new), with
FakeAgentDevice as the upstream.
- release-all while an agent holds a device sends agent-device a release with the agent's principal as tenant and the run ID, lease provider and client ID of the allocate reply, plus its device key when the reply names one
- release-all: the driver's reclaim of the device starts only after agent-device answers that release
- a command still running at release-all finishes before the driver's reclaim of that device starts
- while the host waits for that release, the device stays
reclaimingand a second agent's open for the same model is not granted it - once the reclaim ends, a second agent's open is granted that device
- a simlock lease that expires, one an operator releases with
lease.release, and one whose device is lost each send agent-device the same release before the reclaim starts - after release-all, the agent's next heartbeat answers LEASE_ENDED_BY_HOST and makes no new simlock lease
- the agent's own release, answered before simlock leasing ends the lease, leaves agent-device with no second release
- the agent's own release that agent-device answers with an error, with a non-JSON body, or cannot reach keeps the record: a later release-all sends the participant's release
- release-all while the agent's own release is in flight waits for it; when it succeeds, no second release is sent and the reclaim starts after it answers
- release-all while the agent's own release is in flight: when it fails, or its CLI hangs up, the participant sends its own release before the reclaim
- release-all while the agent's own release hangs: at lease.endWaitMs the wait ends and the reclaim starts
- the agent's release sent while the participant's release is in flight answers released false, and the reclaim starts once the participant's release answers
- an agent holding two devices: release-all sends one release per lease, each with that lease's scope
- a simlock lease from a plain client, with the edge on, ends with nothing sent to agent-device and its reclaim starts at once
- with agentDevice.enabled false, release-all reclaims at once, as today
- agent-device answering the release with an error: the device's reclaim still starts
- agent-device unreachable for the release: the device's reclaim still starts
- after the agent-device daemon restarts, release-all gets released false from it and the reclaim starts at once
- after a simlock daemon restart on the same data directory, a lease made before the restart ends at startup with nothing sent to agent-device
- an allocate reply with a run ID of 129 characters, and one with a client ID holding
/: the front releases the agent-device lease and the simlock lease and answers a COMMAND_FAILED error - (host) the recording wrapper given those two replies records no entry
- (host) release-all while the allocate for that lease is still in flight waits for it; when it succeeds, the participant sends the release with its scope, and a release reply arriving after the participant started writes nothing to the record
- (host) with
AgentDeviceUpstream.current()unavailable(starting), the participant drops the entry and sends nothing - a lease ended by
nuke(already aborted signal) and one ended aftersettlebegan send nothing to agent-device - a command held past lease.endWaitMs: the reclaim starts at the ceiling and the host's release request is aborted; a second agent's open of that device answers DEVICE_IN_USE and leaves no simlock lease until the command is let go, then is granted
- a daemon stop while the host waits for a release, with the held command set to outlive its HTTP request, aborts the participant's request and the stop finishes before the command is let go
nuke.runwhile the host waits for a release aborts that request and the reset goes on- FakeAgentDevice refuses a release missing the tenant, the run ID, the lease provider, the client ID or the device key with LEASE_SCOPE_REQUIRED, and one with a different client ID with LEASE_SCOPE_MISMATCH, as agent-device does
- FakeAgentDevice answers an allocate for a device a live lease holds with DEVICE_IN_USE, and a release of a lease with a held command only after the command is let go
- the agent's own release sent after release-all ended its simlock lease answers agent-device's reply,
released: false, since the participant already released that lease
Assumption: an agent's release after simlock ended its lease passes through agent-device's released: false; the existing tests at edge.test.ts that asserted released: true there change to released: false (decided in delivery, confirmed by the maintainer 2026-10-13)
- an allocate reply whose device key is
ios:mobile:<id>is recorded, and release-all sends that device key in the release
Done when
- Every Tests line above passes with
pnpm exec vitest run --project unit <file>. - Nothing in
src/leasing/orsrc/core/imports fromsrc/agent-device/:
pnpm lintpasses with simlock runs the agent-device daemon #470's import rule, whichsrc/lint-rules.test.ts
proves. - Nothing in
src/agent-device/front/reads or writes the release scope
record (a reviewer checks the imports).
Out of scope
- The port, its ceiling, its stop signal and the
lease.endWaitMsconfig key
(Leasing calls lease-end participants before it reclaims a device #469). - The front map, how the front learns a lease ended and the answers it
gives, and agent-device daemon restarts (Heartbeats keep the simlock lease alive #473). - The allocate failure paths and raising the forwarded
ttlMs(An agent opens and closes a device through simlock #472). - Proving it on real devices: the release-all scenarios in End-to-end tests: agent-device through a simlock worker on real iOS and Android devices #477's worker-mode
suite do that. - Gateway mode: the host would sit on the worker and the front on the
gateway; the relay adapter is a later feature.
Depends on
- Leasing calls lease-end participants before it reclaims a device #469
- Heartbeats keep the simlock lease alive #473
Approval
- Approved for delivery
Written by an agent.
Activity
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the spec check (host-side scope recording, one reply-success rule, bounded wait for the agent's release with fallback, scope bounds, fake device claim, no host-to-front marking, new tests).
Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated:
simlock nukerestarts the agent-device daemon (maintainer decision); signal reasons, supervisorrestart(), and the accepted failed-release gap.Written by an agent.
V3RON commentedon Oct 12, 2026
Spec updated: fixes from the second spec check; nuke restart removed; in-flight allocate waited for; participant owns the entry once started; unavailable daemon handled; host-level tests.
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, #469, #473 closed.feat(agent-device): simlock ends the agent-device lease before it wip…
chore(agent-device): satisfy Fallow on the lease-end participant (#474)
test(agent-device): the agent's release after release-all answers rel…
docs(components): list the lease-end participant and release scope re…
test(agent-device): tests prove what their titles claim; COMPONENTS n…
fix(agent-device): drop the lease-end timer; the renewal-between test…
test(agent-device): a release the CLI hung up on counts as aborted, a…
chore(agent-device): drop a stale fallow suppression (#474)
fix(agent-device): accept agent-device's real device keys (ios:mobile…
test(e2e): agent-device through a simlock worker on real devices (#509)
chore(agents): audits probe every test, reviews stop overlapping, sea…