Skip to content

refactor: route application lifecycle through runtime facts - #1759

Draft
thymikee wants to merge 28 commits into
refactor/adr19-boot-unitfrom
refactor/adr19-open-close-runtime-unit
Draft

refactor: route application lifecycle through runtime facts#1759
thymikee wants to merge 28 commits into
refactor/adr19-boot-unitfrom
refactor/adr19-open-close-runtime-unit

Conversation

@thymikee

@thymikee thymikee commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Draft ADR 0019 Wave 3 lifecycle sibling for #1739, based on refactor/adr19-boot-unit at 3ed473400. It moves the canonical open, prepare, close, and internal runtime descriptors behind package-owned lifecycle uses while keeping daemon request/session policy and public response construction.

  • Adds the four parametrized runtime-cutover rows, R19R22, for openTarget, prepareAppleRunner, closeTarget, and runtimeHints; no fifth policy file was added. The descriptor declares the complete close-with-hint-clear use, so close never reaches through an unavailable runtime-hints sibling.
  • Facts are the only support authority. Each admitted handler performs one side-effect-free inspectFacts call and exactly one plan-specific bind. The capability bucket, requireCommandSupported wiring, legacy adapter/maps/tags, and out-of-binding foreground/shutdown/port-reverse dispatch are removed.
  • Ownership comes from the selected binding owner, never ambient request/provider scope. Unsupported provider cells fail closed without ADB, simctl, runner, or local lifecycle fallback. A cloud iOS session now persists its exact appleOs: 'ios' device leaf during preparation; a collapsed Apple record remains unsupported even if an owner label says ios.
  • Open-target classification, Apple runner preparation, close semantics, runtime hints, and shutdown mechanics live with their owning package/runtime. The former root generic lifecycle host and capture-kit generic lifecycle role are gone. Limrun lifecycle facts require a live interactor rather than a stale session record.
  • Android test IME is durable device state: both recovery records must persist before switching; failed first/second writes make zero switch calls and preserve an existing valid marker. Recovery reads back before clearing, expires the session ref before mutation, and closes/recovery restores before marker removal.
  • Gateway shutdown detaches before daemon session teardown for handoff, stops after resource finalization, and terminates each still-owned generation at most once. macOS close-time alert dismissal is contained in the close unit.

This remains a provisional sibling-stack draft. Final replay/readiness is blocked on the shared #1740/#1745/#1747 prerequisite resolution, remaining CI/review audit, and the live-device evidence gaps listed below.

Descriptor parity and fact coverage

Each column is independently matched to the legacy descriptor/dispatch cell; it does not inherit support from a sibling lifecycle operation. openTarget is resolveOpenTarget + prepareApplicationOpen + openApplication; closeTarget is closeApplication + finalizeApplicationClose; runtimeHints is applyRuntimeHints + clearRuntimeHints.

Canonical denominator cell family openTarget prepareAppleRunner closeTarget runtimeHints
Local Apple: iOS/iPadOS/tvOS/visionOS simulators (4) yes yes yes yes
Local Apple: matching physical devices plus macOS simulator/device (6) yes yes yes no
Local Apple: other emulator leaves plus watchOS simulator/device (8) no no no no
Local Android: mobile emulator/device (2); synthetic simulator (1) yes / no no yes / no yes / no
Local HarmonyOS: emulator/device (2); synthetic simulator (1) yes / no no yes / no no
Local Linux: desktop device (1); synthetic emulator/simulator (2) yes / no no yes / no no
Local Vega: TV emulator (1); physical/mobile/desktop/simulator sentinels (4) yes / no no yes / no no
Local web: browser device (1); non-browser kinds (2) yes / no no yes / no no
WebDriver provider: Android-device/mobile + iOS-device/mobile (2); all other cells (33) yes / no no yes / no no
Limrun provider: Android-emulator/mobile + iOS-simulator/mobile with live interactor (2); all other/stale cells (33) yes / no no yes / no no

The table-driven regression covers all 105 cells (35 local, 35 WebDriver, 35 Limrun) for each of the four operations. It compares every fact with its operation-specific legacy cell, asserts that false facts omit the concrete operation, and proves provider-owned unsupported/shutdown/recovery paths make zero local ADB/simctl/runner calls.

Durable-tier and route evidence

  • Validated descriptor/marker recovery covers corrupt markers, partial acquisition/persist/adopt/transfer gaps, lazy startup admission, forced disposal, idempotence/fencing, exact-owner recovery, and operation-over-cleanup error precedence.
  • Planted-red structural variants for duplicate facts admission and a retained direct local lifecycle route are rejected by the cutover gate. Changing Android clearRuntimeHints to unavailable makes its focused parity test fail the two expected supported cells; restored code is green.
  • Production-route regressions prove one facts admission/one bind for each descriptor, zero binds on false facts, provider-owner fail-closed behavior, close without runtime-hints loading, and Android cleanup once when close is supported. Race tests cover IME first-write/second-write failure, recovery read-back before clear, and pre-mutation ref expiry.
  • Live Android evidence: on Pixel 9 Pro XL emulator emulator-5554, opened Settings with --test-ime, observed the valid recovery marker and persisted Gboard prior state, then closed with the marker removed. Killing only the isolated verification daemon and reopening with --no-test-ime restored Gboard and removed both recovery records.

Size accounting

Same-host/toolchain measurement at b382e3f2a6475daabe84f99efedc68b19f505006 (seven startup samples):

Metric 44c298d7f Head Delta
Raw JavaScript 2,036,067 B 2,233,908 B +197,841 B (+9.717%)
Gzipped JavaScript 659,646 B 730,712 B +71,066 B (+10.773%)
npm tarball 797,027 B 882,284 B +85,257 B (+10.697%)
npm unpacked 2,781,186 B 3,020,751 B +239,565 B (+8.614%)
Metric Immediate stack base 3ed473400 Head Unit delta
Raw JavaScript 2,183,020 B 2,233,908 B +50,888 B (+2.331%)
Gzipped JavaScript 714,183 B 730,712 B +16,529 B (+2.314%)
npm tarball 840,013 B 882,284 B +42,271 B (+5.032%)
npm unpacked 2,926,675 B 3,020,751 B +94,076 B (+3.214%)

Text-diff source accounting excluding tests/fixtures: root src/ adds 114,947 B and removes 69,908 B (net +45,039 B); packages/ adds 101,093 B and removes 9,910 B (net +91,183 B). The production net growth is itemized rather than attributed to stack headroom:

Area Net text-diff bytes
Contracts +29,122 B
Local platform packages +49,421 B
Provider packages +15,361 B
Root lifecycle host/gateway +24,021 B
Daemon lifecycle/recovery +13,597 B
Root Android durable IME +7,421 B
Cutover/layering gates +11,716 B
Capture-kit deletion -2,721 B
Metadata and other scripts -196 B

Validation

  • pnpm check:affected --run && git push passed from a clean committed tree at b382e3f2a6475daabe84f99efedc68b19f505006. The affected gate passed format, lint, typecheck, build/package, layering, coverage, provider integration, replay compatibility, and daemon-wire compatibility; native/device lanes remain GitHub-authoritative.
  • Focused lifecycle/provider route suites passed. A transient combined provider scenario marker-read failure passed in isolated rerun and the full affected provider suite, consistent with the documented contention class rather than a reproducible regression.
  • No public CLI help, docs, or skills changed: this is an internal routing/lifecycle migration preserving command behavior.
  • Residual live evidence gaps: macOS, physical iOS/tvOS, Apple prepare/hints, and a live provider lifecycle target. Keep this PR draft until those gaps, prerequisite replay, CI, and review findings are resolved.

ADR 0019 §6 (amended): every command descriptor declares its platform-execution
mode explicitly. Adds the `none` mode to `CommandPlatformExecution`, removes the
silent `{ kind: 'legacy' }` default at registry entry, and annotates all 76
descriptors so the migration denominator is machine-readable.

Part of #1739 (wave 0)
`react-devtools start` on a Limrun Android instance dispatches internal
`runtime port-reverse`, which reaches a provider device runtime, so ADR 0019 §6
`none` is false for it. Reclassify as `legacy` and add the derived coherence gate
that catches delegated platform execution: if a CLI route for command R
dispatches command D, R may declare `none` only when D is `none`.

Part of #1739 (wave 0)
Subtracting attributed command NAMES let a stray dispatch hide behind a routed
one that names the same command, so the gate's totality claim did not hold.
Dispatch sites now carry their source offset and attribution subtracts
occurrences.

Part of #1739 (wave 0)
An unknown literal or computed command target resolved to undefined and never
entered the scan, so a dispatch could evade attribution by naming a target the
gate could not read. Daemon-send envelopes are now located by their send call and
an unresolvable target is reported instead of skipped.

Part of #1739 (wave 0)
… seam

The syntactic scan recognized only a direct sendToDaemon call whose first
argument was an inline object literal, so a variable envelope or a computed
callee was omitted from every result. Rather than teach the scanner more shapes,
the CLI's injected dispatches now flow through one typed construction point whose
route/command pairs are declared, and the gate reads that declaration instead of
recovering it from syntax.

Part of #1739 (wave 0)
…zed gate

ADR 0019 §8: the per-command cutover gates consolidate into one parametrized
runtime-command-cutover gate driven by a table of migrated commands. Adding a
migrated command adds a row; the mechanism carries one planted-red proof instead
of one per command.

Part of #1739 (wave 0)
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.18 MB 2.23 MB +45.2 kB
JS gzip 714.2 kB 729.5 kB +15.4 kB
npm tarball 840.0 kB 850.7 kB +10.7 kB
npm unpacked 2.93 MB 2.97 MB +45.7 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.2 ms 25.9 ms -0.3 ms
CLI --help 65.2 ms 64.7 ms -0.5 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/snapshot.js +51.1 kB +15.2 kB
dist/src/src2.js +44.6 kB +13.4 kB
dist/src/runtime3.js +30.8 kB +8.8 kB
dist/src/dispatch.js -9.4 kB -2.4 kB
dist/src/session.js -5.7 kB -2.4 kB

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 4ee2025. Changes requested.

[P1] This does not establish the ADR 0019 ownership seam. Platform/provider packages mostly classify facts and call host.applicationLifecycle.bind(...); the one root host then lazy-loads the 545-line open module and 236-line close module, where Apple/Android/provider mechanics, readiness, runner, IME, perf, and macOS behavior still execute. Move concrete lifecycle operations behind their owning platform/provider adapters and keep root composition limited to narrow neutral host capabilities.

[P1] ADR 0019 defines one canonical descriptor as the abandonment-safe migration unit. This PR batches open, prepare, close, and runtime across 128 files, changes all four discriminators to device-runtime, and adds no lifecycle cutover rows. Split the descriptors and give each its parametrized cutover row plus a production-route planted-red witness. The reported Android fact flip proves a package fact test, not removal of legacy or parallel shipped execution.

[P1] The direct base #1747 and prerequisites #1740/#1745 remain draft. Live evidence covers one Android-emulator open/close/test-IME recovery scenario, but not Apple-only prepare, runtime-hint apply/clear, macOS/physical Apple, or provider paths. CI also still has jobs in progress, so this is not ready.

[P2] The immediate delta is +104,691 source bytes and +40,614 raw shipped JS (+13,881 gzip), with no pre-start per-descriptor budgets. New and expanded files exceed project tripwires, including the 545-line root open implementation and 1,961-line close-shutdown test. This exceptional growth is a symptom of the combined root-owned design; splitting ownership should materially reduce it.

The facts-first admission, plan-specific single binds, exact provider resolver capture, durable IME ordering/recovery, and fail-closed handler tests otherwise look coherent. No ready label until ownership, unit scope/gates, budget, live evidence, dependencies, and CI are resolved.

@thymikee

Copy link
Copy Markdown
Member Author

Adversarial review of new lifecycle draft exact head 4ee2025e: not clean. Confirmed blockers:

  • Add parametrized cutover rows for open, prepare, close, and runtime; declare close’s hint-dependent use in its descriptor plan.
  • Bring full routes behind admitted bindings: open --foreground currently probes local iOS before facts; close --shutdown uses ambient provider/legacy shutdown; runtime port-reverse bypasses its declared use; open/close still wrap the legacy dispatcher.
  • Remove ambient provider ownership from open claims/close shutdown; derive policy from the selected runtime owner and add a provider-binding/no-ambient regression.
  • Make stale Limrun lifecycle facts require a live session before bind.
  • Fence Android IME startup recovery against the fire-and-forget open interleaving.
  • Expire the old ref frame immediately before any mutating preparation/hint-clear operation, not only final launch/close.
  • Move lifecycle semantics into owning packages. The current root universal lifecycle host retains cross-family mechanics, while capture-kit was expanded into forbidden generic platform-common. Root production grows ~1,902 LOC.
  • Correct tarball/unpacked evidence to authoritative CI values.
  • Decompose the new 545-line lifecycle-open implementation and extract additions from 648-line session-close; split the +295-line shutdown test by concept.

Live evidence covers only Android emulator open/IME recovery; macOS, physical Apple, and live-provider lifecycle evidence remain absent. Keep draft, address independently, run pnpm check:affected --run before push, and request another exact-head review.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-1759/

Built to branch gh-pages at 2026-08-12 06:59 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head adversarial rereview of b382e3f2: not clean. Confirmed blockers:

  1. P1 — legacy close execution still ships. Close is device-runtime but retains dispatch: {} and DISPATCH_HANDLERS.close in the generic dispatcher. Delete the legacy close projection and ensure R21 enforces retirement.
  2. P1 — package ownership remains a repackaged root dispatcher. Broad lifecycle host/tool bags and monolithic Interactor semantics still route package modules into root dynamic imports of legacy platform/daemon mechanics. Replace with narrow low-level ports and package-owned mechanics; preserve laziness.
  3. P1 — open performs platform-specific target/surface policy before facts inspection/bind. Move all Apple/Android resolution/classification behind the admitted owner; facts must be first.
  4. Attributable CI — Integration/Coverage are red. Migrated Android open always tries test-IME activation, but clean fixtures lack the bundled manifest, causing missing-artifact failures and leaked claims. Fix the production/fixture contract and prove cleanup; rerun full affected gate.
  5. Evidence/size: refresh tarball/unpacked from authoritative CI and continue decomposing touched >300 LOC concepts where locality warrants it.

The four cutover rows, declared plans, single inspect/bind, fail-closed selection, Limrun liveness, IME lock, ref expiry, capture-kit cleanup, Linux/Vega modules, and R7/R9/R10 are fixed. Keep draft; independently classify/rerun both smoke failures and retain missing Apple/provider live-evidence blockers.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head b382e3f2. This is not ready.

  • P1 — Android test-IME behavior regressed. The migration lost both the provider-owned exclusion and the documented best-effort handling: emulator-default enableTestIme now unconditionally awaits local bundled-IME activation. Exact-head Integration and Coverage consequently report 30 failures across 7 files with “Android test IME … helper artifact … not found.” Restore provider exclusion and best-effort behavior at the typed owning boundary, and plant a provider/emulator regression that fails on this head.
  • P2 — lifecycle ownership remains inverted. The package binders now own sequencing, but broad root AppleApplicationTools/AndroidApplicationTools implementations still dynamically import and own concrete runner, simulator/device-target, runtime-hint, and IME mechanics. Replace these service bags with focused platform-owned capabilities.
  • P2 — cutover is incomplete. close still has dispatch: {} and DISPATCH_HANDLERS.close remains shipped; R21 only retires the deleted wrapper, not this legacy route. R19–R22 also collide with active-stack rule allocations, and open still performs platform/surface target policy before facts admission.

The branch is CLEAN/MERGEABLE but authoritative Integration, Coverage, Android Smoke, and iOS Smoke are red. Live evidence covers only an Android-local happy path, not provider or Apple/macOS/physical parity. The exceptional bundle/source growth and several near-tripwire modules also still need tightening. No readiness label.

…e admission

Android open regained a hard failure when the bundled test-IME helper artifact
cannot be read, which fails every default-on emulator open. Only the durable
recovery-record fence rejects now; helper acquisition warns and falls back.

open/prepare/close/runtime/boot each carried their own requireXFacts,
requireXBinding and xUnavailableResponse trio. One admission module owns the
single side-effect-free inspection and the bind, and the cutover gate proves
that promise at the shared module instead of re-detecting it per handler.
… alias module

The neutral RuntimeHintValues payload had its transport keys re-listed by
platform-apple, platform-android and the daemon recovery path. The contract owns
the key set now and every gate reads it.

src/platforms/android/ime-recovery-fence.ts was three aliases of contract
exports; callers use the contract directly and the cutover gate keys on the real
symbol.
…e direct lifecycle binding

The migrated Android scenarios assert the durable test-IME path, but the IME
helper had no provider-supplied artifact override, so activation could only ever
work from a built npm bundle. It now follows the same precedence the snapshot and
touch helpers already use, and the scenarios supply a fixture artifact — which is
what makes android-lifecycle and android-find prove the broadcast channel instead
of falling back to shell input text.

harmonyos/linux/vega/web/webdriver/limrun each hand-rolled the same open, close,
target-resolution and unavailable-operation bodies. One contracts factory owns
that shape; each package declares its target identity and relaunch semantics.
…zation branches

createComposedPlatformRuntimeGateway named Android test-IME recovery and Apple
runner shutdown directly. The host composes which durable resources exist; the
gateway keeps only the startup fence, the lazy host load and the once-per-process
shape of each phase. Its cutover gate now rejects a gateway that names any
platform durable owner, and proves the marker-evidence gate at the new owner.

The two session finalization entry points duplicated the finalize/clear-hints
pair; they now share one body and differ only in how they bind and dispose. The
daemon path binds lazily, so an unsupported cell still fails on facts alone.
@thymikee

Copy link
Copy Markdown
Member Author

Took this over at b382e3f2, ran a thermo-nuclear quality pass, and pushed 4cef1f36. Production source is -217 net lines (+624/-841) across 47 files, and the whole pnpm check:affected --run gate passes from a clean committed tree.

Attributable CI (blocker 4) — root cause found, fixed, and the scenarios now prove the real path

The migrated Android open dropped the try/catch that made test-IME activation best-effort, and the comment that replaced it justified the wrong thing:

Durable marker persistence is a pre-mutation fence. A failed fence is not an optional optimization: abort the open rather than silently continuing after an unprotected switch.

activateAndroidTestIme never throws for a failed fence — it returns persistFailed: true without switching. The only thing the removed catch was suppressing is helper acquisition (missing/unbuilt bundled artifact, device-side install rejection), which is exactly the case that must fall open. Since test IME is default-on for emulators, that made every open fail on a checkout without the artifact. 7 provider-integration files were red; all 50 are green now.

Two of them (android-lifecycle, android-find) were red for a second reason. They assert the IME broadcast channel and explicitly assert shell input text is never used — but the IME helper had no provider-supplied artifact override, so activation could only ever succeed from a built npm bundle. It now follows the precedence the snapshot and touch helpers already have (adbProvider.imeHelperArtifact), and the scenarios supply a fixture artifact. Those assertions now pass for the right reason instead of being unreachable.

Regression coverage: src/__tests__/platform-runtime-android-application-tools.test.ts — an unobtainable helper resolves, a failed durable fence still rejects. Proven red against the pre-fix tool.

Package ownership (blocker 2)

createComposedPlatformRuntimeGateway named androidApplications.hasTestImeRecoveryEvidence, recoverTestImeStartup and appleApplications.detach/finalizeRunnerSessionsForShutdown directly — a neutral gateway that knows Android IME. Which durable resources exist is now the host's composition (platform-runtime-application-resources.ts); the gateway keeps only the startup fence, the lazy host load and the once-per-process shape of each phase. The cutover gate was rewritten accordingly: it now rejects a gateway that names any platform durable owner, and proves the marker-evidence ordering at the new owner, with two planted-red tests.

Duplication the review hadn't itemised

  • 5 copies of the facts-admission trio. open, close, prepare, runtime and the pre-existing boot each declared their own requireXFacts / requireXBinding / xUnavailableResponse. All five throw the identical runtime-gateway-missing error that RequestExecutionScope already throws. One src/daemon/runtime-admission.ts owns the single side-effect-free inspection and the bind. The gate no longer enumerates five hand-picked helper names: each route must make exactly one shared admission call, inspectFacts/bindDevice are forbidden call names in every lifecycle handler, and the one-inspection/one-bind promise is proven once at the shared module.
  • 6 packages hand-rolling the same lifecycle binding. harmonyos/linux/vega/web/webdriver/limrun each wrote their own open, close, target resolution and unavailable() — 548 lines that differed only in an owner label, whether the target carries a bundle id, and whether relaunch closes first. (limrun's target resolver was the same rule as harmony's, written as nested ternaries.) One bindDirectApplicationLifecycle factory in contracts; each package is now a ~28-line declaration.
  • 3 copies of the transport-hint predicate over the "deliberately neutral" RuntimeHintValues, each re-listing metroHost/metroPort/bundleUrl in platform-apple, platform-android and daemon recovery. The contract owns the key set; hasRuntimeTransportHints delegates to it.
  • src/platforms/android/ime-recovery-fence.ts was three aliases of contract exports. Deleted; the gate keys on the real symbol.
  • The two session-finalization entry points each duplicated the finalize/clear-hints pair (4 copies of the same effects). One body now; they differ only in how they bind and dispose. The daemon path binds lazily, so an unsupported cell still fails on facts alone instead of allocating an owner first.

No file crosses 1000 lines.

Still open (unchanged by this pass)

The live-evidence gaps stand: macOS, physical Apple, Apple prepare/hints, and a live provider lifecycle target. Dependencies #1740/#1745/#1747 are still draft. Keep this draft until those close.

@thymikee
thymikee force-pushed the refactor/adr19-boot-unit branch from 3ed4734 to a200645 Compare August 12, 2026 07:26
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact head 4cef1f36. The ownership/facts-first delta is directionally sound, but this remains not ready.

[P1] createAndroidApplicationTools().activateTestIme catches every rejection from activateAndroidTestIme while intending only helper acquisition/install failure to be best-effort. It therefore also swallows startup-recovery-fence rejection, recovery-lock failure, and transport/read failures after durable state work, allowing open to continue with recovery incomplete or mutation state unknown. The regression mocks one generic AppError and consequently blesses that overbroad catch. Represent helper unavailability/install refusal as a typed non-activation outcome (or catch a structured reason at the acquisition seam), let fence/lock/post-record failures propagate, and plant both cases through the shipped route.

[P1 integration] This sibling still assigns R20/R21/R22 to prepare/close/runtime while #1758 assigns those same IDs to install/reinstall/install_source on the same boot base. The row model does not reject duplicate rule IDs, so rebase/consolidate against one accepted catalog before either sibling merges. The PR is also DIRTY after its base advanced and requires full stack replay.

Exact-head Android Smoke is red at wait text Automation lab; because this delta changes Android open/IME behavior, it requires owner investigation and a green rerun rather than an infrastructure assumption. The PR body remains at b382e3f2, with stale size/validation evidence and no disclosure of the red lane or dirty base. Exceptional growth still needs explicit acceptance, and live provider/macOS/physical-Apple lifecycle evidence remains absent. No ready-for-human label.

@thymikee
thymikee force-pushed the refactor/adr19-boot-unit branch 3 times, most recently from fcb2f35 to 9d2337f Compare August 12, 2026 09:42
An error occurred while trying to automatically change base from refactor/adr19-boot-unit to refactor/adr19-parametrized-cutover-gate August 12, 2026 09:55
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