Skip to content

fix: end the agent-device connection and host session dirs of a hosted macOS app - #2527

Merged
janicduplessis merged 2 commits into
mainfrom
fix/2522-agent-device-cleanup
Oct 5, 2026
Merged

janicduplessis merged 2 commits into
mainfrom
fix/2522-agent-device-cleanup

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Description

After stim stop of a hosted macOS app, the client's agent-device connection stays bound to that app's remote config, so the next hosted workspace's --remote-config fails with "A different remote connection is already active" until agent-device disconnect runs by hand. On the hosting Mac, each hosted session also leaves ~/.stim/server/agent-device/sessions/stim.<session>_* directories behind.

Solution

Client: stopHostedMacos (reached by stop, worktree remove and gc through stopMacosApp) now ends the agent-device connection before it stops the host session, only when the placement's agent driver is agent-device. It runs agent-device connection status --json and acts only when agent-device reports connected with a remoteConfig whose realpath equals this workspace's config. It then runs close --remote-config <file> --session <reported session> and disconnect --session <reported session>, so a connection to anything else (another hosted app, an EAS simulator) is never touched. The calls go through exec.ts, use the same agent-device lookup as the device-teardown cleanup, are bounded to 30 s, and are best effort: failures print one stderr line and never block the stop. This runs before the host session stops because close needs the host's proxy and the config file.

Server: AgentDeviceDriver.revoke removes the session's agent-device directories (<state>/agent-device/sessions/stim.<session>_*) once the lease is released (also when the lease DELETE fails), and stop removes the revoked sessions' directories again once the daemon is gone. Live sessions' directories survive a daemon restart, and nothing else under sessions/ is touched.

stim guide macos and website/docs/macos.md document both behaviours.

Test plan

  • pnpm run format:check, lint, build, typecheck, knip pass.
  • pnpm test on hosted-macos-client, guide, macos, agent-device-driver and agent-driver: 143 tests pass. New cases cover the matching connection (status, close, disconnect in order, config file and host session still present), a different or disconnected connection (nothing run), a symlinked config path, no agent-device on PATH, a failing close (disconnect and stop still run), and the server directory removal (target stim.<S>_* dirs gone; sessions/default and another session's dir kept; a revoked session's directory recreated before stop is removed after the daemon exits while a live session's is kept).
  • Real tool: the CLI built from this branch against the agent-device 0.21.20 macos-app lease build (feat(remote): add a host-allocated macos-app lease backend callstack/agent-device#3236) and the Mac mini host. A fixture app was hosted twice in a row from two workspaces; stop printed "closed agent-device connection for default", agent-device connection status --json then reported connected: false, and the second workspace's open --remote-config worked without a manual disconnect. The server part is verified after deploying this merged commit to the mini (result in a follow-up comment).

Fixes #2522

The code was written by Codex gpt-6.1-sol.

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the diff against origin/main (correctness first). No blocking bugs. Three small confirmed issues and some notes.

Confirmed

  1. Driver crash-restart wipes the session directories of sessions that are still live (packages/server/src/agent-device-driver.ts, stop()). AgentDriverRegistry.lost() in agent-driver.ts calls driver.stop() after the daemon exits, then start() and issue() again for every live app. stop() now sweeps stim.<session>_* for every lease in this.leases, and those leases are for apps that keep running and get re-issued a moment later. The agent's session loses its request logs (requests/*.ndjson) and repair tombstone across a daemon restart. The same sweep runs on the failed-restart stop() calls inside the retry loop. The issue only asks for removal when the hosted session ends. Sweeping only in revoke() and in the final stop (when no live app remains) would match that. If you keep it, the restart case should have a test.

  2. A failed lease DELETE leaves the directories behind for good (revoke()). this.leases.delete(session) runs first, so if adminRequest(..., 'DELETE', ...) throws, the lines this.released.add(session) and this.removeSessionDirectories(session) are never reached. AgentDriverRegistry.drop() catches and logs the error. After that the session is in neither leases nor released, so even stop() will not sweep it. Moving the released.add and the removal into a finally fixes it.

  3. The released set has no test. It exists so that stop() re-sweeps a revoked session after the daemon is gone, in case the daemon recreated the directory in between. Nothing recreates a directory after revoke() and then calls stop(), so deleting released would leave every test green. The new stop test also does not prove the "after daemon teardown" ordering in its title. Its directories are removed whether the sweep runs before or after teardown. A test that recreates stim.<session>_default after revoke() and before stop() covers both.

Notes (low, no action required)

  • agent-device leaves .ad replay scripts as files directly in sessions/ (<sanitized session>-<timestamp>.ad, from the installed 0.21.12 dist, resolveScriptPath). removeSessionDirectories only removes directories (entry.isDirectory()), so a recorded script for a hosted session would stay. I did not confirm that a hosted macos-app lease ever records one, so this may not apply.
  • Docs (guide/macos.ts, website/docs/macos.md) say the connection that "agent-device reports as connected to that remote config" is closed. In practice connection status with no --session reports one connection only (the default or active session). A connection under another --session name that was made with this config is left alone. The cleanup is also skipped silently when agent-device is not on PATH. One clause on each would make the docs exact. "When the hosted session ends" is really "when the app stops or the driver stops".
  • In hosted-macos-client.test.ts the expect(...) calls inside the mocked runFile throw into closeAgentConnection's own catch. A failing ordering assertion is swallowed and shows up only indirectly, through calls or stderr. This works, but those checks are weaker than they look.

Dismissed

  • Touching another connection or another session's directory. The sweep matches readdirSync entry names against stim.${session}_ and joins them to the sessions dir, so no path from the caller is used and traversal is impossible. Session ids come from randomUUID() (device-host.ts) and a UUID has no _, so stim.<uuid>_ cannot prefix another session. On the client, the realpath comparison of the workspace's config with the status remoteConfig limits action to that config. A missing config file, a symlink and a different config are covered by tests.
  • Failure escaping stop. All three agent-device calls, the executable lookup and the JSON parsing sit inside one try/catch in closeAgentConnection, and each of close and disconnect has its own catch, so disconnect still runs when close fails. I ran agent-device 0.21.12 against a throwaway --state-dir. connection status --json and disconnect --json return success: true. close --json for an absent session exits 1 with success: false, which the code handles.
  • Order of close then host stop. The remote config still exists when close runs, and if the host stop fails afterwards a retry finds the connection already gone and does nothing.
  • Races in revoke / issue. issue() revokes the same session first, which is correct for a new pid. revoke awaits the in-flight renewal before the DELETE, as before.
  • Blocking runFile. At most 30 s of sync wait in stop; acceptable since it is best effort and only runs when a connection matches.
  • Comment and ASCII policy. The diff adds no comments and no non-ASCII characters in sources or tests. The child_process rule is respected because the new code goes through getExecutor().runFile.

@janicduplessis
janicduplessis marked this pull request as ready for review October 5, 2026 19:21
@janicduplessis
janicduplessis merged commit acbf044 into main Oct 5, 2026
11 checks passed
@janicduplessis
janicduplessis deleted the fix/2522-agent-device-cleanup branch October 5, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted macOS agent control: stim stop leaves agent-device bound to the old remote config, and host session dirs pile up

1 participant