Skip to content

fix(daemon): retain Apple runners for leases kept on close - #4195

Merged
thymikee merged 1 commit into
callstack:mainfrom
muvaf:codex/retain-client-lease-runner
Nov 3, 2026
Merged

thymikee merged 1 commit into
callstack:mainfrom
muvaf:codex/retain-client-lease-runner

Conversation

@muvaf

@muvaf muvaf commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Keep a healthy iOS simulator runner when its client-owned lease survives session close. Preserve ownership after session retirement so release and expiry stop the runner; retry failed cleanup and protect replacement sessions. Docs now specify graceful daemon shutdown.

Sixteen files; lifecycle fix requested in #4174. Allocate with retainOnClose: true; close ends the session, and leases.release() ends the retained lease and runner.

Related work

Independent siblings: Android installer #4194, recordings #4196. Apple hooks follow #4194; native Limrun XCTest follows all layers to replace AX.

Validation

Base a2c169fa502a53f1f2a1da25b1cd16b9c731af39; tested head ea3dbea24f2f71f631226c0ced937f7ab37cb505.

pnpm check:affected --run --base <base> --head <head> passed (exactCommit:true): 17,152 unit tests, one skipped; 304 focused tests. Independent delta review passed. This round changes wording only.

Simulator evidence remains attributed to db876b9 (identical runtime): 45 successful SDK calls covering served XCTest commands, retained reuse, release, expiry, shutdown and nonretained controls. Cleanup/race regressions fail without their fixes.

Physical-device comparison and current receipts: iPhone 14 Pro, iOS 27.0.1. Stock replay fails on both base/head at step 3 because General is off-screen; a temporary copy adding scrolling/waits passes on both. All test sessions and daemons cleaned up; no device claims remain. Stock lane remains a recorded limitation.

GitHub owns coverage/provider/device checks skipped locally. Vercel requires maintainer authorization.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

View guided diff

@vercel

vercel Bot commented Nov 3, 2026

Copy link
Copy Markdown

@muvaf is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/core/platform-resource-cleanup.ts">

<violation number="1" location="packages/daemon/src/core/platform-resource-cleanup.ts:19">
P3: `leaseWillEnd` is also `true` when lease inspection fails, yet the PR keeps that lease for recovery, so the name overstates what is known. Document the failure case on the field so callers do not read it as a confirmed lease end.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

shutdownRequested: boolean;
hasScreenRecording: boolean;
hasLease: boolean;
leaseWillEnd: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: leaseWillEnd is also true when lease inspection fails, yet the PR keeps that lease for recovery, so the name overstates what is known. Document the failure case on the field so callers do not read it as a confirmed lease end.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/core/platform-resource-cleanup.ts, line 19:

<comment>`leaseWillEnd` is also `true` when lease inspection fails, yet the PR keeps that lease for recovery, so the name overstates what is known. Document the failure case on the field so callers do not read it as a confirmed lease end.</comment>

<file context>
@@ -16,6 +16,6 @@ export type PlatformResourceCleanup = Readonly<{
     shutdownRequested: boolean;
     hasScreenRecording: boolean;
-    hasLease: boolean;
+    leaseWillEnd: boolean;
   }): boolean;
 }>;
</file context>
Suggested change
leaseWillEnd: boolean;
/** True when the lease is not kept on close, or when its close decision could not be inspected. */
leaseWillEnd: boolean;

@thymikee

thymikee commented Nov 3, 2026

Copy link
Copy Markdown
Member

At 6b3c132 a retained runner can outlive its lease, and the live lifecycle run is missing. There are no conflicts.

Closing a local or proxy simulator session with retainOnClose retires the session but keeps its runner; release and expiry enumerate existing sessions, so they cannot stop that runner. The XCTest process and ownership can outlive the lease indefinitely when idle stop is disabled and another session keeps the daemon alive. Please preserve lease-to-host ownership after session retirement and have the shared release and expiry path stop retained hosts under device execution locks, reusing cleanupSessionlessExecutionHost and protecting replacement owners. Teardown

Could you provide accessible commands and outputs tied to the head SHA, built daemon and simulator that show retainOnClose allocation, a served runner command, close with the lease present and session absent, reopening with the same runner generation, and release after another close stopping that generation while the daemon remains running? Please also show expiry cleanup, close --shutdown, graceful daemon shutdown after retained close, nonretained controls and final cleanup. The linked evidence was inaccessible, and the available summary does not establish cleanup at release. Runner path

Could the existing lease-end owner enumerate retained execution hosts after preserving their lease association before session retirement, while keeping the existing Apple idle policy and cleanup port?

One lower-priority inline thread still applies.

Not blocking: You can take or leave updating the CLI help and session documentation to describe the retained-lease exception.

The sole failing check is Vercel deployment authorization, corroborated by its bot comment and apparently unrelated to this daemon change. Before merge, fix release and expiry cleanup and provide accessible head-attributed lifecycle evidence; Vercel separately needs team deployment authorization.

@muvaf
muvaf force-pushed the codex/retain-client-lease-runner branch from 6b3c132 to 94a4db6 Compare November 3, 2026 18:00

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 13 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/server/http/daemon-runtime.ts">

<violation number="1" location="packages/daemon/src/server/http/daemon-runtime.ts:305">
P2: `retryPending()` runs every second from the expiry sweep with no backoff or attempt cap. A retained host whose cleanup keeps failing will re-stop the runner under the device lock and log a warning each second. Throttle retries per lease, for example with a next-attempt timestamp or exponential delay.</violation>
</file>

<file name="src/commands/schema/cli-help.ts">

<violation number="1" location="src/commands/schema/cli-help.ts:483">
P2: `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS=0` does not retain a lease-backed runner until daemon exit; releasing or expiring its `retainOnClose` lease still triggers lease-end cleanup. Clarify that `0` disables only idle stopping and that lease end takes precedence.</violation>

<violation number="2" location="src/commands/schema/cli-help.ts:483">
P2: Lease release or expiry does not always stop the retained runner: a replacement session can take over the same device first, and lease-end cleanup deliberately preserves that runner. Qualify this statement with “when no replacement session has taken it over” in the CLI help.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

);
const leaseExpirySweep = setInterval(() => {
leaseRegistry.consumeExpiredLeases();
void leaseSessionEnd.retryPending();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: retryPending() runs every second from the expiry sweep with no backoff or attempt cap. A retained host whose cleanup keeps failing will re-stop the runner under the device lock and log a warning each second. Throttle retries per lease, for example with a next-attempt timestamp or exponential delay.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/server/http/daemon-runtime.ts, line 305:

<comment>`retryPending()` runs every second from the expiry sweep with no backoff or attempt cap. A retained host whose cleanup keeps failing will re-stop the runner under the device lock and log a warning each second. Throttle retries per lease, for example with a next-attempt timestamp or exponential delay.</comment>

<file context>
@@ -300,10 +300,10 @@ async function startDaemonRuntimeOnHost(
-  );
+  const leaseExpirySweep = setInterval(() => {
+    leaseRegistry.consumeExpiredLeases();
+    void leaseSessionEnd.retryPending();
+  }, LEASE_EXPIRY_SWEEP_INTERVAL_MS);
   leaseExpirySweep.unref();
</file context>

Comment thread src/commands/schema/cli-help.ts Outdated
open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops the runner even after the session closes. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS=0 does not retain a lease-backed runner until daemon exit; releasing or expiring its retainOnClose lease still triggers lease-end cleanup. Clarify that 0 disables only idle stopping and that lease end takes precedence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/commands/schema/cli-help.ts, line 483:

<comment>`AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS=0` does not retain a lease-backed runner until daemon exit; releasing or expiring its `retainOnClose` lease still triggers lease-end cleanup. Clarify that `0` disables only idle stopping and that lease end takes precedence.</comment>

<file context>
@@ -480,7 +480,7 @@ Android physical-device prerequisites:
   open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
   No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
-  close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
+  close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops the runner even after the session closes. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
   Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap.
   A client starting a local daemon waits while the daemon process stays alive and keeps publishing startup progress, so an overloaded host makes startup slower, not failed. It stops at a ceiling (default 120 s; set AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS to a positive integer number of milliseconds to override) and names the outcome in error details.reason: startup-exited (the process ended before registering), startup-stalled (alive, no startup progress for a fixed 60 s window the ceiling variable does not change), or startup-ceiling (a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. Only a progressing own launch outlasts the 60 s stall window, so a ceiling set below 60 s also ends a silent own launch this way). details.phases carries the startup phase timings it observed and details.startupTimeoutMs the effective ceiling. Branch on details.reason, never on the message. The variable is read only when a client must start a daemon: a value that is not a positive integer fails with INVALID_ARGS before the daemon spawns, and a client that only attaches to or reuses a running daemon never reads it.
</file context>

Comment thread src/commands/schema/cli-help.ts Outdated
open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops the runner even after the session closes. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Lease release or expiry does not always stop the retained runner: a replacement session can take over the same device first, and lease-end cleanup deliberately preserves that runner. Qualify this statement with “when no replacement session has taken it over” in the CLI help.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/commands/schema/cli-help.ts, line 483:

<comment>Lease release or expiry does not always stop the retained runner: a replacement session can take over the same device first, and lease-end cleanup deliberately preserves that runner. Qualify this statement with “when no replacement session has taken it over” in the CLI help.</comment>

<file context>
@@ -480,7 +480,7 @@ Android physical-device prerequisites:
   open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
   No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
-  close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
+  close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops the runner even after the session closes. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
   Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap.
   A client starting a local daemon waits while the daemon process stays alive and keeps publishing startup progress, so an overloaded host makes startup slower, not failed. It stops at a ceiling (default 120 s; set AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS to a positive integer number of milliseconds to override) and names the outcome in error details.reason: startup-exited (the process ended before registering), startup-stalled (alive, no startup progress for a fixed 60 s window the ceiling variable does not change), or startup-ceiling (a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. Only a progressing own launch outlasts the 60 s stall window, so a ceiling set below 60 s also ends a silent own launch this way). details.phases carries the startup phase timings it observed and details.startupTimeoutMs the effective ceiling. Branch on details.reason, never on the message. The variable is read only when a client must start a daemon: a value that is not a positive integer fails with INVALID_ARGS before the daemon spawns, and a client that only attaches to or reuses a running daemon never reads it.
</file context>

@muvaf
muvaf force-pushed the codex/retain-client-lease-runner branch from 94a4db6 to db876b9 Compare November 3, 2026 18:23

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/schema/cli-help.ts">

<violation number="1" location="src/commands/schema/cli-help.ts:483">
P2: These lines promise retained-runner cleanup on an unqualified daemon shutdown, but cleanup only runs during graceful shutdown. Say `graceful daemon shutdown` in this help text and `client-api.md` so users do not assume cleanup after a crash or forced kill.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread src/commands/schema/cli-help.ts Outdated
open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops its runner unless a session outside the lease has taken over the device. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stopping. Lease release, expiry, and daemon shutdown still clean up runners they own. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: These lines promise retained-runner cleanup on an unqualified daemon shutdown, but cleanup only runs during graceful shutdown. Say graceful daemon shutdown in this help text and client-api.md so users do not assume cleanup after a crash or forced kill.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/commands/schema/cli-help.ts, line 483:

<comment>These lines promise retained-runner cleanup on an unqualified daemon shutdown, but cleanup only runs during graceful shutdown. Say `graceful daemon shutdown` in this help text and `client-api.md` so users do not assume cleanup after a crash or forced kill.</comment>

<file context>
@@ -480,7 +480,7 @@ Android physical-device prerequisites:
   open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
   No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
-  close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops the runner even after the session closes. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
+  close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops its runner unless a session outside the lease has taken over the device. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stopping. Lease release, expiry, and daemon shutdown still clean up runners they own. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision.
   Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap.
   A client starting a local daemon waits while the daemon process stays alive and keeps publishing startup progress, so an overloaded host makes startup slower, not failed. It stops at a ceiling (default 120 s; set AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS to a positive integer number of milliseconds to override) and names the outcome in error details.reason: startup-exited (the process ended before registering), startup-stalled (alive, no startup progress for a fixed 60 s window the ceiling variable does not change), or startup-ceiling (a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. Only a progressing own launch outlasts the 60 s stall window, so a ceiling set below 60 s also ends a silent own launch this way). details.phases carries the startup phase timings it observed and details.startupTimeoutMs the effective ceiling. Branch on details.reason, never on the message. The variable is read only when a client must start a daemon: a value that is not a positive integer fails with INVALID_ARGS before the daemon spawns, and a client that only attaches to or reuses a running daemon never reads it.
</file context>

@thymikee

thymikee commented Nov 3, 2026

Copy link
Copy Markdown
Member

At db876b9 the lease-release cleanup is fixed: failed inspection, retry deadlines, idle stopping and replacement ownership are all handled, and I found no new code issues. One open thread on the graceful cleanup wording still applies.

The other threads from the last round are fixed and can be resolved.

Your evidence artifacts cover the local SDK, daemon and XCTest validation. Coverage now passes; the only failing check is the Vercel deploy, which needs team authorization for this fork. There are no conflicts.

@muvaf
muvaf force-pushed the codex/retain-client-lease-runner branch from db876b9 to ea3dbea Compare November 3, 2026 19:33
@thymikee

thymikee commented Nov 3, 2026

Copy link
Copy Markdown
Member

The code looks good at ea3dbea. The reworded help now says graceful shutdown stops retained runners, which settles the last open shutdown wording thread; the retry, idle-stop and replacement-session threads no longer apply either. Your live lifecycle evidence is linked in the PR body.

All other checks pass; the only failing check is the Vercel deploy, which needs team authorization for this fork. There are no conflicts.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 3, 2026
@thymikee
thymikee merged commit d249344 into callstack:main Nov 3, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants