Skip to content

simlock ending a simlock lease ends the agent-device session first #474

Closed

Description

@V3RON

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 like ios:mobile:<id>). Example: tenant
    tok_ci1, run ID proxy-3f2a…, lease provider proxy. A release that
    lacks one of them is refused with LEASE_SCOPE_REQUIRED; one that names a
    different value, with LEASE_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
Loading
# 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
Loading

(* = changed, new = new file.)

  • src/agent-device/shared/:
    • The release scope reader (new). It reads tenantId, runId,
      leaseProvider, clientId and deviceKey from the lease in an
      allocate reply. tenantId and runId must be present. Each field
      present must be a string of 1 to 128 characters of [A-Za-z0-9._-],
      except deviceKey, 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 a COMMAND_FAILED
      error.
  • 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
    upstreamFetch the 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 in src/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.
  • The participant, for each lease Leasing calls lease-end participants before it reclaims a device #469 hands it:
    1. 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, when AgentDeviceUpstream.current() is not running: a
      daemon that is starting or restarting lost every lease with its
      process.
    2. 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.
    3. Sends agent_device.lease.release with the full recorded release
      scope through AgentDeviceUpstream.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's authenticate needs the agent's simlock
      token, which the host never holds. It does not use the agent-device
      daemon's admin lease DELETE.
    4. 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.
  • 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 answers UNKNOWN_LEASE to
    the next renew.
  • src/agent-device/index.ts exports what startDaemon needs to register
    the participant.
  • src/daemon/main.ts: with agentDevice.enabled true, startDaemon
    passes the participant in leaseEndParticipants to createLeasing. 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's dispose (Heartbeats keep the simlock lease alive #473), which runs in stopAuxiliary before
    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: FakeAgentDevice gains 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, answers DEVICE_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, answers UNAUTHORIZED with reason LEASE_SCOPE_REQUIRED;
      a different value answers reason LEASE_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.

#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
Loading

Facts this relies on, read from the agent-device code (branch
fix/session-ends-with-lease, PR #3480):

  • lease_release releases the registry entry first. It then awaits
    endSessionsForLease before it replies (src/daemon/handlers/lease.ts).
    That runs endLeaseSessions under 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).
  • getLease refuses a release without tenant and run ID, or without a
    lease provider, client ID or device key the lease has
    (assertLeaseOwnerScope, assertLeaseScopeMatch in
    src/daemon/lease-registry-scope.ts). The proxy CLI allocates with lease
    provider proxy, a client ID and run ID proxy-<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.

Rules in play

  • architecture.md rule 5: the wait is a direct call through the port,
    never a lease.released handler.
  • 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/ or src/core/ imports the edge. The host fills the record
    from the host side, so the front stays the same for a relay adapter.
  • safety.md rule 10: the release scope comes from agent-device's own
    allocate reply, and is checked before it is stored.
  • testing.md rule 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 reclaiming and 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 after settle began 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.run while 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/ or src/core/ imports from src/agent-device/:
    pnpm lint passes with simlock runs the agent-device daemon #470's import rule, which src/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

Depends on

Approval

  • Approved for delivery

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 12, 2026
  2. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    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.

  3. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: simlock nuke restarts the agent-device daemon (maintainer decision); signal reasons, supervisor restart(), and the accepted failed-release gap.

    Written by an agent.

  4. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    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.

  5. V3RON commented on Oct 12, 2026

    @V3RON
    ContributorAuthor

    Spec updated: closure-check findings fixed (maintainer asked to fix all).

    Written by an agent.

  6. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 13, 2026
  7. github-actions commented on Oct 13, 2026

    @github-actions

    Now task:ready: approved, technical spec present, #469, #473 closed.

  8. self-assigned this
    on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions