Skip to content

feat: add direct Limrun provider runtime#1278

Merged
thymikee merged 23 commits into
mainfrom
codex/limrun-direct-rebase
Jul 16, 2026
Merged

feat: add direct Limrun provider runtime#1278
thymikee merged 23 commits into
mainfrom
codex/limrun-direct-rebase

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Adds agent-device connect limrun backed by a direct Limrun provider runtime for iOS and Android.

The shared provider runtime remains limited to lease lifecycle, inventory, interactor dispatch, install hooks, optional port reverse hooks, and shutdown. Limrun API calls, assets, ADB tunnel handling, Android snapshot/helper reuse, and iOS tree mapping remain local to src/providers/limrun.

Android provider interactor composition now lives in core so future direct Android providers can reuse the same ADB-backed interaction stack without importing platform internals. The runtime tags Limrun requests and instance metadata with agent-device identity.

Validation

  • pnpm check:affected --base origin/main --run
  • pnpm check:layering
  • pnpm exec vitest run src/__tests__/limrun-runtime.test.ts src/platforms/android/__tests__/app-lifecycle-open.test.ts
  • Live Limrun Android Gesture Lab and helper-backed snapshot verification completed before this rebase; iOS and Android direct sessions were also exercised.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.8 MB +23.2 kB
JS gzip 557.4 kB 565.6 kB +8.2 kB
npm tarball 673.1 kB 679.7 kB +6.6 kB
npm unpacked 2.4 MB 2.4 MB +23.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 30.2 ms 29.9 ms -0.4 ms
CLI --help 62.3 ms 60.1 ms -2.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/apps.js -7.0 kB -2.1 kB
dist/src/internal/daemon.js +4.7 kB +1.3 kB
dist/src/cli.js +1.8 kB +414 B
dist/src/cli-help.js +714 B +191 B
dist/src/session.js +755 B +173 B

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-16 17:26 UTC

@thymikee

Copy link
Copy Markdown
Member Author

Thermo-nuclear code-quality review

Overall this is a well-shaped PR. The ProviderDeviceRuntime seam is the right home for this, splitting device.ts/snapshot.ts out of the runtime was the right instinct, and hoisting the withAndroidAdbProvider Proxy from the Limrun runtime into createAndroidInteractor (core/interactors/android.ts) is exactly the canonical-layer move — the ADB-provider scope now composes generically for any future direct Android provider without importing platform internals. Good.

That said, there are a few structural issues I'd want addressed before merge. Two are load-bearing.


1. configureDirectPortReverse is inert in production — a de-facto test-only seam

react-devtools.ts grows an option (configureDirectPortReverse?, L30), a gate (shouldConfigureDirectReverse, L159), and a call site (L180). But the only caller of runReactDevtoolsCommandcli.ts:212 — never passes configureDirectPortReverse. So the gate's options.configureDirectPortReverse !== undefined clause (L169) is always false in production, the branch never fires, and the only thing that exercises it is the unit test that injects a vi.fn().

This is precisely the pattern the No test-only DI seams guard exists to catch; it passed here only because the seam is an optional callback rather than a flagged shape. Functionally it's dead branching that reads as "direct RDT port-reverse is wired" when it isn't.

Please either wire it at the cli.ts call site (the daemon already exposes configureProviderPortReverse, so the callback has a real home) or drop it from this PR until the consumer exists. Shipping the gate + option + branch with no production producer is debt, and it's the kind of thing that quietly becomes permanent.


2. Android port-reverse: bespoke ownership tracking duplicates the canonical helpers, and stacks on top of them

runtime.ts hand-rolls a full port-reverse ownership/idempotency/conflict layer:

  • session.reversedPorts: Map<number, string> (L77)
  • ensureAndroidPortReverse (L656) — idempotency (existing === name → return) + conflict throw (already exists)
  • removeAndroidPortReverse (L699)
  • createLimrunAndroidPortReverseProvider (L486)
  • portReverseResult (L331)

All of this already exists canonically in platforms/android/adb-executor.ts: createExecAndroidPortReverseProvider (owner tracking) + createAndroidPortReverseManager (idempotency + "already owned by" conflict). The Limrun exec (runLimrunAndroidAdb) already runs adb -s <serial> …, which is exactly the executor those helpers expect.

Worse than plain duplication: because the runtime defines its own reverse provider, the app-lifecycle path now double-books the same ports. ensureAndroidLocalhostReverse (app-lifecycle.ts:278) does createAndroidPortReverseManager(resolveAndroidAdbProvider(device)) — inside a Limrun scope resolveAndroidAdbProvider returns the Limrun provider, so the canonical manager's active map is layered directly on top of the bespoke reversedPorts map. Two trackers, same ports, same idempotency/conflict logic implemented twice.

Code-judo move: build one canonical reverse provider per session and back both entry points with it. Roughly:

// once, in createAndroidSession (closes over session, lazily starts the tunnel via runLimrunAndroidAdb):
session.reverse = createExecAndroidPortReverseProvider(
  (args, options) => runLimrunAndroidAdb(session, args, options),
);

Then createLimrunAndroidAdbProvider sets reverse: session.reverse, configurePortReverse/removePortReverse (L167/L179) delegate to session.reverse.ensure/remove with tcp:${port} endpoints, and cleanupAndroidAdbTunnel uses list()/removeAllOwned() instead of iterating reversedPorts. That deletes ~90 lines (the four helpers + the map + most of tcpEndpointPort) and removes the stacked double-tracking. The per-session persistence you (correctly) need is preserved by storing the single provider instance on the session — which is the actual reason the bespoke map exists, and the one thing the ephemeral per-call managers don't give you.


3. inferAndroidAppName collides with — and shadows — the canonical export

runtime.ts:763 defines a private inferAndroidAppName(packageName): string | undefined (last-segment heuristic). platforms/android/app-lifecycle.ts:176 already exports inferAndroidAppName(packageName): string, and it's the more capable version (filters com/android/google/… noise tokens). Reuse the canonical one; a same-named, weaker private copy is the textbook bespoke-duplicate smell and will drift.


4. runtime.ts (775 lines, new) mixes runtime orchestration + iOS interactor + Android transport

Under the 1k line, so not a hard blocker — but this new file already spans three concerns that want to be separate, and you've established the split pattern with device.ts/snapshot.ts:

  • the LimrunRuntime class + lease/inventory/install dispatch/shutdown
  • LimrunIosInteractor + iOS install/asset packaging (L345–L430, L522–L654)
  • Android adb exec + tunnel + port-reverse + install (L371–L520, L656–L745)

Splitting into runtime.ts + ios.ts + android.ts drops the orchestration file well under ~350 lines and stops the iOS/Android tagged-union from forcing both platforms to share a module. Worth doing while the code is fresh.


Minor

  • readLimrunLeaseIdFromInventoryRequest (device.ts:42) is an identity wrapper — it returns request.leaseId. Inline it at the one call site; the named helper adds indirection without buying clarity.
  • parseLimrunDeviceId (device.ts:32) returns null while its siblings (platformForLimrunLeaseBackend, getSessionForDevice, etc.) return undefined. Pick one absent-value convention.
  • ensurePersistentAndroidAdbSerial (L712) isn't concurrency-safe: two parallel adb calls in one session (the Android platform code does fan out) can both see !adbTunnel and each call startAdbTunnel(), leaking a tunnel. Memoize the in-flight promise on the session. The AndroidAdbExecutor contract explicitly says implementations must be safe to call concurrently for one request.

None of these are correctness-critical to the happy path, and the core architecture is sound — but #1 and #2 are real structural debt (inert branch; duplicated + stacked port-reverse), and both have a clean path to deleting code rather than rearranging it. I'd resolve those two before merge.

@thymikee thymikee force-pushed the codex/limrun-direct-rebase branch from 14a76a7 to 4042d69 Compare July 16, 2026 08:16
@thymikee

Copy link
Copy Markdown
Member Author

Blocking review finding at 4042d69d813589326978a5e10293aa64e2955a3f:

connect limrun validates credentials in the CLI process, but an already-running daemon may have started without a Limrun runtime. In that state, lease_allocate can still succeed because provider allocation is optional; later inventory resolution can fall through to local devices. A Limrun-tagged lease must fail clearly unless a matching active runtime owns and allocates it. Please add a regression covering an existing daemon with no Limrun runtime and a leaseProvider: "limrun" allocation request.

The latest architecture/device-selection fixes and all 23 checks are green, but this runtime-availability gap blocks merge because it can silently execute against the wrong device class.

@thymikee

Copy link
Copy Markdown
Member Author

Thermo-nuclear code-quality review — direct Limrun provider runtime

Reviewed the full diff against the merge-base (13b3d4fc8). The architecture is sound: reusing the existing ProviderDeviceRuntime contract and lifting Android interactor composition into core/interactors/android.ts so a direct provider can inject its own AndroidAdbProvider transport and reuse the whole interaction stack is exactly the right move — the Android side is a model of doing this well. No file crosses 1k lines; docs are accurate; the new tests are meaningful and not redundant.

The notes below are what I'd want addressed before merge. Two are should-fix; the rest are reuse/cleanup that this PR is a good moment to land. I also flag one structural direction and one process item.

🔴 Should-fix

1. onLeaseExpired is dead wiring — and it's the missing half of the new close semantics.
This PR adds onLeaseExpired to LeaseRegistry (src/daemon/lease-registry.ts:27, fired at :364 and :377) but nothing ever passes it — not daemon-runtime.ts:91, not session.ts:287, not any test. Meanwhile the session-close refactor now retains the session + lease when a provider release fails (releaseProviderLeaseForClose → returns retriable, skips sessionStore.delete), on the premise that eventual expiry cleans up. With the expiry hook unwired, a provider-owned lease that TTL-expires leaks its cloud instance until daemon shutdown. Either wire onLeaseExpired to call the provider release (the safety net the new close flow implicitly depends on), or delete the option + both fire-sites. Right now it's write-only plumbing that also hides a real gap.

2. looksLikeUrl reinvents isDeepLinkTarget — and is weaker.
src/providers/limrun/ios.ts:264 gates open() (openUrl vs launchApp) with a bespoke /^[a-z][a-z0-9+.-]*:/i regex. The canonical isDeepLinkTarget (src/contracts/open-target.ts) is used for exactly this decision everywhere else (core/dispatch.ts, platforms/apple/core/app-launch.ts, platforms/android/app-lifecycle.ts, …). It's strictly stronger: it rejects embedded whitespace and requires // after http/https/ws/wss/ftp/ftps. looksLikeUrl misclassifies e.g. http:foo as a URL and would openUrl it instead of launching the app. Import the canonical helper.

🟡 Reuse-from-core (the thing you asked me to hunt for)

3. runnerContext is threaded through getInteractor but ignored by every provider. ProviderDeviceRuntime.getInteractor(device, runnerContext) (provider-device-runtime.ts:33) is plumbed core → provider, but both implementations discard it (limrun/runtime.ts:100 and cloud-webdriver/runtime.ts:182, both _runnerContext). It's tested for propagation but no production code reads appBundleId/requestId off it. Drop the param until a consumer exists, or leave a comment naming the intended one — speculative generality otherwise.

4. iOS install derives a worse app name than the codebase already computes. inferAppNameFromPath (ios.ts:259) strips the extension off the local filename (→ MyApp-1.2.3-release). readIosBundleInfo (platforms/apple/core/install-artifact.ts:84) reads the real CFBundleDisplayName/CFBundleName from the same local .app's Info.plist — and this runs on the local FS before upload (prepareLimrunIosAsset), so there's no remote-divergence excuse. Export readIosBundleInfo and use it.

5. Android adb errors bypass androidAdbResultError. requireSuccessfulLimrunAndroidAdb (android.ts:181) throws a raw AppError('COMMAND_FAILED', …). The canonical androidAdbResultError (adb-executor.ts:353) runs the result through classifyAdbFailure/attachAdbFailureHint (offline/timeout/connection-dropped → actionable hints + retriable). Today Limrun errors get classified only incidentally, because calls happen inside createAndroidInteractor's withAndroidAdbProvider Proxy — that's a coincidence, not a guarantee. Call androidAdbResultError directly.

6. Small dups: sleep is reimplemented as raw setTimeout (ios.ts:134) — use src/utils/timeouts.ts sleep. normalizeOptionalString is defined verbatim in both ios.ts:254 and android.ts:222 — share one copy. resolveIosTarget's settings → com.apple.Preferences (ios.ts:250) duplicates ALIASES in platforms/apple/core/app-resolution.ts:17.

🟢 Minor

  • getAndroidPortReverseSession(leaseId, throwOnIos: boolean) (runtime.ts:260) is a flag-argument smell — the bool flips between throw (configure) and silent-undefined (remove). Two tiny helpers or an options object would read better.
  • react-devtools direct-reverse policy is split across layers: cli.ts hardcodes ports 8097/8097 and always passes the callback; react-devtools.ts shouldConfigureDirectReverse hardcodes the leaseProvider==='limrun' && leaseBackend==='android-instance' gating. Neither layer owns the full "when/what to reverse" policy — consider consolidating it (port + condition) in the limrun connection/provider policy.
  • gesture-lab-android.ad: the coordinate rescale turned the second gesture fling right … into a second gesture fling left … — rightward fling is no longer exercised (the wait "fling 2" only counts flings, so it won't catch it). Looks like a stray global sed; intended?
  • toIosSelector (snapshot.ts:104) maps both text and label keys to { label } — confirm text selectors are meant to match on AXLabel.

🧭 Structural direction (not blocking)

The iOS side reimplements a ~270-line LimrunIosInteractor from scratch while Android reuses createAndroidInteractor. That asymmetry is justified todaycreateAppleInteractor (platforms/apple/interactor.ts) is hardwired to the local XCTest runner (runAppleRunnerCommand, simctl openIosApp), so there's nothing to inject into. But that's the honest "code judo" target: Android already has a transport seam (AndroidAdbProvider); iOS doesn't. Giving the Apple interactor the same seam would let this provider (and the next one) reuse tap/type/scroll/snapshot instead of re-deriving them. Worth a follow-up issue.

⚙️ Process

The branch is 2 commits behind main (merge-base 13b3d4fc8). Since it forked, main made check:production-exports baseline-free (#1276/#1282) and removed the rotate alias (#1277/#1283). This PR doesn't touch those files so it won't revert them, but please rebase and re-run the now-baseline-free check:production-exports — that hardened check has never run against this branch, and the provider work adds a lot of new exports.


Net: request changes on #1 and #2, land the reuse cleanups (#3#6) while you're here, and file the iOS-interactor-seam follow-up. The core design is good.

@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (ee1ac81be): the stale-daemon Limrun fall-through is fixed, but the new runtime-availability guard introduces a blocking regression for connect proxy.

providerRuntimeIds contains direct provider runtimes only; proxy is an established lease provider with daemon-local lease allocation and is not a direct runtime ID. assertProviderRuntimeAvailable() currently rejects every requested provider absent from that list, so proxy-backed open/lease allocation now fails before its normal lease path.

Please restrict the guard to providers that require direct runtime ownership (or otherwise model provider kind explicitly), and add a regression proving leaseProvider: proxy remains allocatable when providerRuntimeIds is present while unavailable Limrun still fails before lease creation.

@thymikee thymikee force-pushed the codex/limrun-direct-rebase branch from ee1ac81 to 9997097 Compare July 16, 2026 13:11
@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (73ddb8b02): the proxy runtime-availability regression is fixed, and the alert test change is a valid timing stabilization. Two provider lifecycle blockers remain.

  1. Remote Limrun iOS sessions enter local simulator refresh on a subsequent open --relaunch. Limrun devices are represented as kind: "simulator", so refreshSessionDeviceIfNeeded() tries to resolve the synthetic limrun:ios:<lease> ID through local CoreSimulator inventory, then can fall back by name. Provider devices already bypass similar local Apple paths elsewhere. Skip local refresh for an active provider device and add an end-to-end Limrun iOS relaunch regression.

  2. Expiry release is fire-and-forget after the registry permanently deletes the lease. releaseExpiredProviderLease() catches and only logs a provider deletion failure; Limrun retains the remote session, but no lease remains to trigger or authorize another release. A transient provider failure therefore leaks the remote instance until daemon shutdown. Keep a retryable cleanup record or otherwise make provider expiry release durable/retryable, with a failure-then-success regression.

@thymikee

Copy link
Copy Markdown
Member Author

Current-head re-review (02b63bd1d), independently confirmed: the local CoreSimulator refresh/app-resolution/runner-notification guards are improved, but two lifecycle blockers remain.

  1. Provider iOS open --relaunch still does not actually relaunch. These devices are kind: simulator, so collapseSimulatorRelaunch skips the provider pre-close and sends terminateRunningApp: true on the open. That flag is a local Apple/simctl optimization; LimrunIosInteractor.open() ignores it and only calls launchApp, so an already-running app remains alive. Exclude active provider devices from the collapsed simulator path (or make the provider implement equivalent terminate-before-launch semantics). The new integration test only asserts two open calls and has no fake close implementation, so reverting the semantic fix would still pass; assert the provider terminate/close call and ordering.

  2. Expired provider cleanup is retryable only while this daemon process remains alive. provider-lease-expiry.ts stores pending leases solely in memory, while daemon shutdown synchronously cancels its timer without awaiting/draining in-flight work; a restart reconstructs an empty queue. LimrunRuntime.shutdown() also allSettleds deletion and clears its session map even when deletion fails. A transient provider outage followed by shutdown/crash can therefore still permanently orphan the cloud instance. Persist a retryable cleanup record across restart (or otherwise prove deletion before discarding identity), and add shutdown/restart plus failure-then-success coverage.

Current CI is green except iOS Smoke in progress and Swift Runner Unit Compile queued, but these code findings block ready-for-human.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head c38e006d13e9414c6a100b4fe2211725b162128a. The new Limrun profile scoping is directionally correct: it requires iOS/Android with the matching instance backend, projects only remote-safe fields, and the provider-iOS fixture now has the right simulator shape. This head is still not ready:

  1. P1 — provider iOS open --relaunch still does not relaunch. Provider iOS is kind: simulator, so session-open.ts still takes the collapsed local-simulator path and skips the provider-backed pre-close. Limrun's iOS interactor ignores terminateRunningApp and only calls launchApp. The updated selector fixture opens once and does not exercise relaunch/termination order.

  2. P1 — expired provider release remains non-durable. Pending releases still live only in an in-memory Map, and daemon shutdown cancels the retry timer. A transient release failure followed by shutdown/crash loses the remote instance identity, so restart cannot resume cleanup. This still needs persisted/recoverable cleanup plus failure → shutdown/restart → success coverage.

  3. P2 — the new Limrun validation test bypasses the public command surface. Public connect accepts --platform, --lease-backend, and --device, but not --target, --udid, --serial, simulator-set, or Android-allowlist flags. The test calls connectCommand directly with udid and target, so those assertions cannot prove CLI behavior; real parsing rejects them earlier as unsupported. Please add parser-level coverage for the intended public contract and at least exercise the reachable --device rejection (or align the schema if those flags are intentionally part of connect).

CI is mostly green; Linux/macOS/iOS smoke and Swift runner compile were still pending/queued at review time. No ready-for-human label while the two P1 findings remain.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head a0f93086fa279a5287c5f3027cdfb11eff06aeb3. The prior provider-iOS relaunch and CLI-reachability findings are fixed: relaunch now dispatches explicit close -> open, with a revert-sensitive provider scenario, and Limrun validation now runs through the public CLI.

Two P1 durability gaps still block readiness:

  1. The persisted expiry queue covers every DEFAULT_PROVIDER_RUNTIME_REQUIRED_ID, but Cloud WebDriver release is not recoverable after restart. A fresh runtime has no in-memory session and returns undefined; an in-process closeSession also converts remote delete/cleanup failures into warnings. releaseExpiredProviderLease treats either non-throwing outcome as success, so it deletes the persisted record without confirmed remote cleanup. Either scope durable recovery to runtimes with a recoverable contract, or make every managed runtime return an explicit handled/success result and recover from durable provider identity. Add failure -> fresh runtime/restart -> successful remote deletion coverage.

  2. persistPendingLeases logs and continues when the record cannot be written. The lease has already been consumed, so if provider release then fails, a crash/restart loses the only cleanup identity. Persistence must fail closed or otherwise retain recoverable identity before remote release is attempted.

All CI checks are green, but that does not override these lifecycle findings. Live provider evidence for iOS relaunch and outage -> restart recovery is also still missing. No ready-for-human yet.

@thymikee

Copy link
Copy Markdown
Member Author

Implemented and pushed 5290255e7 to close the remaining expiry regression. The lifecycle now separates live expiry release from optional restart recovery: every active provider runtime gets its ordinary provider-scoped release attempt with process-local retry, while only providers such as Limrun that can reconstruct cleanup from durable lease identity enter the persisted restart-recovery queue. This restores Cloud WebDriver expiry cleanup without falsely treating it as restart-recoverable.

Added revert-sensitive coverage for non-recoverable live release/retry, provider routing, and a real Cloud WebDriver allocation → expiry release that asserts DELETE /session/wd-1 and empty inventory. Existing Limrun persistence/restart tests remain green.

Validation: focused lifecycle tests 22/22; Cloud WebDriver provider integration 7/7; relevant request-router tests 15/15; build, format, lint, typecheck, layering, and Fallow green. Broad sandboxed suites hit unrelated localhost/host-permission and contention failures; relevant Cloud WebDriver tests passed when rerun with localhost access. Fresh CI is now running on the pushed head.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up 0a977f0e8 fixes the final review catch: LeaseLifecycleProvider.release may successfully return no provider metadata, so a resolved matching release is now treated as handled regardless of payload. Added a regression for metadata-free successful release; focused expiry/provider tests are 8/8, with typecheck and Fallow green. Fresh CI restarted on this exact head.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 16, 2026
@thymikee thymikee force-pushed the codex/limrun-direct-rebase branch 2 times, most recently from 5c2734e to 3dd9354 Compare July 16, 2026 16:39
@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (61b8821b5): P1 regression. The remove Limrun compatibility cleanup refactor deletes the live-only expiry path (releaseExpiredLease, pendingLiveLeases, retry helper, daemon wiring, and regression coverage) for every non-recoverable provider runtime, not only Limrun. BrowserStack/AWS CloudWebDriver runtimes implement ordinary leaseLifecycle.release to delete the WebDriver/provider session but cannot reconstruct cleanup after restart, so they correctly do not expose recoverExpiredLease and are absent from recoverableProviderIds. On expiry, the current releaser therefore returns without calling them; the local lease is consumed while the paid remote session remains live until daemon shutdown.

Restore the process-local release/retry path for active non-recoverable providers (or an equivalent lifecycle-owner cleanup), while keeping persisted restart recovery limited to reconstructable providers. Restore a revert-sensitive CloudWebDriver expiry regression proving the live runtime release/DELETE path runs. This head is not ready-for-human; the label has been removed.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 16, 2026
@thymikee thymikee merged commit 117f781 into main Jul 16, 2026
23 checks passed
@thymikee thymikee deleted the codex/limrun-direct-rebase branch July 16, 2026 17:26
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.

1 participant