Skip to content

refactor(daemon): platform-free daemon — broader migration waves (successor to #1696) #1739

Description

@thymikee

Outcome

Continue ADR 0019 beyond the adoption
checkpoint until the daemon holds no platform behavior: every device command consumes
request-bound platform runtimes, cross-cutting platform policy lives behind owned facets, and
src/daemon/ leaves the R3 platforms-seam allowlist. Migration follows the amendment (ADR 0019
sections 8–10): move-dominated size accounting, tiered evidence, one bind per handler,
evidence-gated startup recovery, two-phase shutdown.

This is the canonical tracker. Denominators and per-unit budgets are recorded here before each
wave starts; a wave's units are not split into worker issues before that.

Start condition: satisfied — the ADR 0019 amendment merged in #1738 (73b54c15c).

Current state — 2026-08-20 (re-derived at 06d27de4d)

Derived from main, not from the hand-written wave plan. Since the previous checkpoint the whole
Wave 4 stack landed on default-branch main: get/read #1877 (494eb52f6), is #1883 (inside
#1877), and wait #1875 (17bdca76c). The read dispatch-alias descriptor was deleted rather
than migrated, which is why legacy drops by four while device-runtime gains three.

Platform execution mode Previous (5bdc1554f) Now (06d27de4d)
none 17 17
inventory 1 1
device-runtime 20 23
legacy 39 35

The parametrized cutover table has 23 rows. Production daemon → platform dependencies are
61 total / 42 value edges (from 62 / 42 at the previous checkpoint; the snapshot start record
was 64 / 45). Capability buckets remaining: 19. The terminal gate is therefore not satisfied
and this tracker stays open.

Completed:

Remaining 35 legacy descriptors, by owner

Owner Count Descriptors
Wave 2 (blocked on #1370) 2 perf, trace
Wave 4 deferred 1 find
Wave 5 interaction 15 click fill type press longpress gesture scroll swipe back home focus hover orientation tv-remote keyboard
Wave 6 named 9 audio clipboard trigger-app-event alert settings react-native app-switcher doctor capabilities
Wave 6 residue (unclaimed by any wave list) 8 batch daemon debug events replay test web react-devtools

Two corrections this re-derivation forced:

  • hover was unassigned. It is a genuine interaction command with a capability bucket, and the
    Wave 5 canonical list below omitted it. It is now recorded on Wave 5, which is why that row reads
    15 rather than 14.
  • The 8 residue descriptors carry no capability bucket. They are legacy-by-delegation: the CLI
    route dispatches another command that executes the platform work, and the mode-dominance gate in
    __tests__/platform-execution-cli-route.test.ts is what keeps them legacy. Most should
    reclassify to none as their delegates migrate rather than need units of their own. That is an
    analysis task, not a migration; nothing may reclassify before its delegate lands.

Remaining 61 daemon → platform edges, by owning unit

Owner Edges Concentration
perf / trace (blocked) 14 session-perf.ts (4), session-perf-legacy.ts, session-native-perf.ts, session-perf-xctrace.ts, types.ts perf fields, part of session-teardown.ts
Wave 5 interaction 9 android-system-dialog.ts (3), interaction-android-escape.ts, interaction-touch-reference-frame.ts, interaction-touch-response.ts, interaction-runtime.ts, selector-runtime.ts
doctor 8 session-doctor-{android,web,toolchain}.ts, session-doctor.ts
alert / audio 6 snapshot-alert.ts (3), session-audio.ts, audio-probe.ts
Wave 6 residue 24 request-platform-providers.ts (6), server/daemon-runtime.ts (4), device-ready.ts (3), snapshot-session.ts, apple-runner-options.ts, request-router.ts, request-recording-health.ts, session-device-utils.ts, install-source-resolution.ts, session.ts, session-test-infrastructure.ts

Remaining critical-path work

  • Wave 2 perf / trace remains sequenced after the next-major deletion in Next major: scheduled breaking cleanups (legacy batch-step shape, deprecated rotate client surface, client-types narrowing) #1370 unless the
    tracker records the approved split escalation.
  • Wave 4 find remains legacy and deferred. refactor: migrate get to the request-bound device runtime #1877 owns its get text leg and the focus unit
    below owns its focus leg; the direct find type execution path still requires the Wave 5
    type unit before an atomic R35 cutover is honest. R35 stays reserved.
  • Wave 4 shared capture debt remains and is still unassigned: the 10 production files under
    src/daemon/snapshot-presentation/ios/, android-snapshot-freshness.ts,
    android-snapshot-timeout-evidence.ts, screenshot-overlay-android.ts, and their
    observation/interaction call sites. They remain live because non-snapshot consumers still depend
    on them. This is the one remaining block of work that is file-disjoint from the descriptor
    units
    , so it is the natural parallel track; it must be assigned before Wave 5 migrates the
    freshness call sites in selector-capture-runtime.ts / deferred-interaction-outcome.ts.
  • Wave 5 interaction descriptors and all Wave 6 closure units/residue remain.
  • Before starting another unit, post its exact denominator, parity source, facet owner, expected
    deletions, evidence tier, and size budget here as required below. This checkpoint does not
    authorize an implicit multi-command migration.

Open critical-path PRs at this checkpoint:

Unit PR Base State
focus (Wave 5, unit 1) #1925 main · 06d27de4d local affected gate green; exact-head lanes running

Immediate critical path: land the focus unit, then type, then the deferred Wave 4 find
R35 cutover — focus clears the first of find's two blockers. Do not start another descriptor
unit in parallel: every unit touches registry.ts, runtime-command-cutover-table.ts,
contracts/platform-runtime-operations.ts and each platform-*/src/runtime.ts, so two in flight
collide on the same four files. The Wave 4 shared capture debt is the one parallel-safe track.

Denominator is derived, not hand-listed

The wave lists below are a plan; the completion denominator is derived and re-derived per wave
from two sources, so a command cannot be silently orphaned:

  1. the descriptor registry — every descriptor whose explicit platformExecution discriminator is
    legacy (wave 0 made the discriminator exhaustive: none | legacy | inventory | device-runtime, with no default; an undeclared discriminator is now rejected);
    and
  2. the platform dependency graph — every production src/daemon/** module with any dependency
    edge on src/platforms/** or a concrete @agent-device/platform-* package: static,
    dynamic, re-export, or type-only. Scope matches the layering scanner's existing exclusion
    of *.test.ts and __tests__/: daemon test files importing concrete platform implementations
    are outside the denominator and outside unit budgets (a unit rewrites the tests of the
    production modules it migrates as part of normal cutover work, but pre-existing
    integration-style test imports are not migration debt). R3 tolerates dynamic and type-only
    edges by design, and legacy daemon behavior reaches platforms through exactly those edges
    (e.g. device-ready.ts), so the R3 seam list is not a valid proxy for this graph.

Units are recorded by canonical descriptor name, with dispatch aliases, the public/internal
pairing where they differ (public install-from-source ↔ internal install_source, which owns the
daemon route), the exact handler owner file, and the cutover-gate row. Aliases (readget,
metricsperf) are surfaced on their canonical unit and are never separate units.

Terminal gate (closes this tracker): a dedicated planted-red layering rule rejecting every
dependency edge — static, dynamic, re-export, and type-only — from production src/daemon/**
modules (test files excluded, matching the layering scanner's scope) to src/platforms/** and to
concrete @agent-device/platform-* packages (daemon types come from contracts only); zero
descriptor capability buckets; zero descriptors with platformExecution: legacy; and
src/daemon/ removed from the R3 platforms-seam allowlist. The daemon platform branch/tag
count reaches its floor rather than a mandated zero: per the accepted end-state ("ideally zero,
practically minimal"), remaining occurrences must each be enumerated and justified as
identifier-only — a platform name flowing through as opaque data or a display label, never
selecting behavior. Non-device daemon commands are outside the denominator because their
discriminator is none, not because a hand-written list omits them.

Locked constraints (from the amendment — summarized, ADR text is authoritative)

  • Move-dominated: root src/ shrinks as packages grow; net shipped-size growth is exceptional and
    itemized. Every unit reports root-bytes-removed vs package-bytes-added plus the checkpoint's four
    size metrics against the 44c298d7f baseline and its stack base.
  • Deprecated surfaces are deleted on legacy at the next major, never migrated. Surface narrowing
    merges before its command's unit (perf's narrowing, refactor: narrow perf API to actionable evidence #1731, is merged).
  • Two evidence tiers: request-scoped units (facts + operations + parity cell table + cutover gate)
    vs durable-resource units (full ADR §4–5 lifecycle evidence). Importing durable machinery
    promotes the tier.
  • One parametrized cutover gate; the four per-command policy files fold into it in wave 0a.
  • Facts replace descriptor capability buckets; each unit deletes its bucket +
    requireCommandSupported wiring with the legacy adapter.
  • One bind per handler for new units; side-effect-free facts inspection for
    admission/capabilities/doctor; one neutral defineUse; preferred operations require a recorded
    measurement.
  • Cross-cutting facets land with their first consuming unit and have one contract owner.
  • R3 platforms-seam allowlist narrows in the same unit that removes an area's last platform
    import; the list never grows.

Waves

Wave 0 — structural substrate trim (behavior-neutral only)

Pure relocation/gate work; a change that alters any production admission, cleanup, or hint
behavior does not belong here.

  • Make the platformExecution discriminator explicit and exhaustive: none | legacy | inventory | device-runtime, remove the { kind: 'legacy' } default at registry entry, and
    annotate every non-device descriptor none. Metadata-only; a registry gate rejects
    undeclared discriminators. This is what makes the derived denominator machine-readable.
  • Parametrized runtime-command-cutover gate; fold device-inventory/logs-runtime/
    network-runtime/record-runtime cutover-policy files into its table; one planted-red for
    the mechanism.
  • Consolidate defineUse (remove per-domain currying wrappers).
  • Absorb the androidAdbExecutor DI slot in request-handler-chain.ts into the existing
    provider-resolution mechanism, with wiring-equivalence tests.

Gateway facts inspection is not a wave-0 item: landing it with zero consumers would violate
the dead-code-clean substrate rule (and trip the fallow production-exports gate). It lands inside
the boot unit — its first consumer.

The record/network handlers keep their admission-use double binds. They were compliant when
they merged; ADR §9 retires the idiom for new units and permits — but does not require — a
retrofit as its own reviewed unit. Retrofitting two working handlers is consistency polish, not
denominator work, and stays off this tracker's critical path.

Wave 1 — request-scoped device lifecycle units (ordered)

Order is a dependency chain, not a preference: boot lands the shared readiness facet the others
consume.

  1. boot — lands the device-readiness facet: ensureReady (covers simulator/emulator boot
    AND physical-device readiness, matching the legacy ensureDeviceReady breadth), headless
    boot as a fact, stopped-AVD-placeholder resolution moved to the Android inventory source,
    Apple runner-cache prewarm becomes platform-internal keep-hot policy (ADR 0002).
  2. shutdownshutdownTarget operation. Eligibility comes from inspecting the
    shutdownTarget availability fact only; the handler never calls ensureReady, and an
    already-stopped target returns success immediately (legacy target-shutdown.ts parity —
    the migration must not boot a target in order to shut it down).
  3. apps — required use: ensureReady + listApps (complete use listed in the descriptor;
    readiness is not hidden inside another operation).
  4. appstate — required use: ensureReady + appState; sessionless path included in the
    denominator.

Deletions per unit: matching branches in session-inventory.ts, session-state.ts,
device-ready.ts, target-shutdown.ts, session-doctor-app.ts; capability buckets.

Wave 2 — perf (durable tier)

Current status: not started; still blocked on the sequencing decision below.

Canonical descriptors: perf (whole descriptor, including its native-perf sub-surface routed via
observability; metrics alias surfaces here) and trace.

The deprecated aggregate forms (perf bare/sample/metrics) cannot remain an action-selected
legacy path inside a migrated perf descriptor — that would be a dual platform-execution path and
fail ADR 0019 §6. Default resolution: sequence this unit after the next major (#1370) deletes
the deprecated forms.
That is the zero-machinery path — deletion retires their platform coupling
per §8, and the surviving narrowed surface migrates as one ordinary descriptor unit. Waves 3–4 do
not depend on perf and proceed in the meantime; the cost is only that the SessionState perf-field
cleanup waits.

Escalation only if the major slips and the perf coupling becomes blocking: a preparatory
behavior-neutral descriptor split (deprecated forms onto a separate internal legacy descriptor,
public surface unchanged) is acceptable only with wire-identity proof — the client sends the
full request including command: 'perf' over agent_device.command, so the wire command, session
events, and saved recorded actions must be byte-identical before and after (gate #1717/#1432 is
the proof vehicle), and the terminal gate then waits on the major deleting the split descriptor.

The surface narrowing (#1731) is already merged, so the denominator is the narrowed surface.

  • Facets: one perf facet with distinct operations (frames, memory sample, native profile/trace),
    facts-declared availability; reuses capture-kit for durable captures.
  • Deletions: SessionState.applePerf + nativePerf.android (R7 shrink in-unit),
    session-perf-xctrace.ts, session-native-perf.ts platform exclusivity, the apple_perf +
    android_native_perf teardown steps, the 12 branch sites in session-perf.ts.

Wave 3 — install family, then open/close

Current status: complete via #1758 and #1759.

Canonical descriptors: install, reinstall, push, install_source (internal; owns the daemon
route — the public install-from-source surface and its capability bucket are recorded on this
unit); then open, prepare, close, and runtime (the runtime-hints setter — it shares the
runtime-hints facet this wave lands).

  • Facets: install/deploy (the session-deploy.ts dispatch table moves fully behind it), open-target
    resolution/classification, runtime-hints (neutral key/value contract), close semantics.
  • Cross-cutting landed here: test-IME as durable device state with marker-based recovery (startup
    orphan restore moves onto the evidence-gated pattern); macOS close-time alert dismissal inside
    the close unit; two-phase gateway shutdown lands with the first unit that deletes a
    daemon-runtime platform shutdown call.
  • Deletions: session-open*.ts branch sites, runtime-hints.ts dispatch, android_ime teardown
    step, restoreOrphanedAndroidTestImeOnDaemonStartup daemon wiring.

Wave 4 — observation

Current status: complete except find. snapshot #1779, diff #1847, viewport #1864,
screenshot #1878, get/read #1877, is #1883 and wait #1875 have all landed on main.
find is the only Wave 4 descriptor left. Its get text leg moved in #1877 and its focus leg
moves in the Wave 5 focus unit; R35 waits on the Wave 5 type unit.

Canonical descriptors: snapshot, find, get (alias read), is, wait, screenshot,
viewport, diff.

  • Facets: capture, snapshot-freshness (Android staleness policy generalized to a hook),
    system-modal probe, overlay source-rect resolution.
  • Post-refactor: migrate snapshot to device runtime #1779 correction: the shared presentation/freshness/overlay work below did not move with
    the command-atomic snapshot descriptor cutover because remaining observation/interaction
    consumers still own it. It remains open Wave 4 debt and must be assigned explicitly before those
    consumers migrate.
  • iOS presentation rules (src/daemon/snapshot-presentation/ios/, originally 6 production files) relocate inside the
    snapshot unit
    , not as a standalone move: the files depend on root-only helpers (React Native
    overlay policy, scroll-indicator helpers, daemon tree helpers) whose owners must be decided
    first — snapshot-domain vocabulary moves to contracts/kernel or a snapshot domain package;
    platform-apple retains no root imports. No generic presentation-registration API while Apple is
    the only rule contributor: the rules sit behind the Apple capture implementation until a second
    platform earns the seam (no-platform-common rule).
  • Deletions: android-snapshot-freshness.ts, android-snapshot-timeout-evidence.ts,
    screenshot-overlay-android.ts, freshness call sites in selector-capture-runtime.ts /
    deferred-interaction-outcome.ts, snapshot-runtime.ts branches.

Wave 5 — interaction (largest)

Current status: prerequisite refactors merged; migration in progress, focus first.

Prework merged first as platform-neutral refactors: #1691 (touch orchestration split), #1656
(selector structural stages into typed policy).

Canonical descriptors: click, fill, type, press, longpress, gesture, scroll,
swipe, back, home, focus, hover, orientation, tv-remote, keyboard. hover was
added by the 2026-08-20 re-derivation: it holds a capability bucket and is a point-addressed touch
command, so the derived denominator surfaced it even though the original hand-written list omitted
it.

Order within the wave is a dependency chain where one exists: focustype unblocks the
deferred Wave 4 find R35 cutover, because both of find's remaining direct execution paths are
those two commands.

  • Facets: interaction dispatch; system-chrome guard + foreground-escape detection (cross-cutting,
    consumed by generic dispatch); tap-outcome corroboration; direct-selector fast path as the one
    measured preferred operation. Full ADR 0011 guarantee-matrix obligations per path.
  • Deletions: platform sites in interaction-touch.ts (densest file), interaction-* handlers,
    android-system-dialog.ts, direct-ios-selector.ts, selector-runtime.ts branches,
    androidBlockingDialogGuard registry flag. Android snapshot-helper ownership moves fully into
    the Android platform module here (its daemon-shutdown reset call moves onto the two-phase stop).

Wave 6 — closure (reaches the terminal gate)

Current status: not started.

Canonical descriptors with capability buckets not owned by earlier waves: audio, clipboard,
trigger-app-event, alert, settings, react-native, app-switcher. Each gets a normal unit
(most are small request-scoped facets; audio already receives an injected backend and may be
mostly bucket deletion).

Named units the derived denominator surfaces even though they carry no capability bucket:

  • doctor — legacy session-route descriptor consuming platform behavior: per-family toolchain
    checks (session-doctor-toolchain.ts dispatch, session-doctor-android.ts,
    session-doctor-web.ts A-class handlers) become platform-contributed check lists behind a
    doctor facet; inventory-failure aggregation already consumes the migrated inventory gateway.
  • capabilities — legacy session-route descriptor projecting support information; migrates
    onto side-effect-free facts inspection as its data source. Deliberately last among command
    units: it projects the union of every migrated command's facts, so it flips when the facts
    surface is complete.

Daemon-runtime residue, each with a named owner:

  • Apple runner-lease state-dir configuration (setRunnerLeaseOwnerStateDir) — moves into the
    Apple module's process-lifetime configuration (two-phase shutdown participant).
  • Web orphan recovery (cleanupWebBrowserOrphansForDaemonStartup,
    cleanupManagedAgentBrowserOrphans) — moves onto evidence-gated startup recovery under the
    web module.
  • iOS simulator runner detach/stop (detachIosSimulatorRunnerSessionsForShutdown,
    stopAllIosRunnerSessions) — becomes the Apple module's two-phase shutdown implementation.
  • Re-derive the denominator; enumerate and burn down any src/daemon/** platform dependency
    edge (static, dynamic, re-export, or type-only) not deleted by earlier waves
    (request-lock-policy.ts switches → runtime-declared lock metadata;
    request-platform-providers.ts roster → generic registration). The wave-0 discriminator
    pass will also classify commands the hand-listed waves don't name (replay, test,
    events, debug, web, …) — whatever it marks legacy lands here if no earlier wave
    claimed it.
  • Flip the terminal gate: land the all-edge-kinds daemon layering rule with its planted-red
    case, remove src/daemon/ from the R3 platforms-seam allowlist, and assert zero capability
    buckets and zero platformExecution: legacy descriptors. If the wave-2 descriptor split was
    taken, this item is blocked on the next major deleting the deprecated perf descriptor.

Independent cleanups (off the critical path)

Platform knowledge without platform imports — not denominator work; do whenever convenient:

  • daemon-client-timeout.ts runs Apple-runner pkill patterns and emits Apple hint text on every
    local timeout regardless of session platform (string-level knowledge; src/daemon/client/ is
    already outside the R3 seam). User-facing hint change; review with before/after fixtures.
  • Optional record/network retrofit onto single-bind facts inspection (permitted by ADR §9 as
    its own reviewed unit; not required).

Validation per unit

  • pnpm check:affected --run; pnpm check for substrate/architecture changes.
  • The parametrized cutover gate row, fact-coverage scenarios for every denominator cell, and the
    tier-appropriate evidence (parity cell table, or full durable lifecycle evidence).
  • Size report: four checkpoint metrics vs baseline + stack base, root-bytes-removed vs
    package-bytes-added, itemized justification for any net growth.

Before each wave

Record here: exact canonical descriptor list (with aliases, public/internal pairings, handler
owner files), denominator cells, the parity source (legacy behavior at which commit), facet
contract owner, expected deletions, and the unit budgets. No wave 2+ unit starts before wave 1 has
landed at least one unit proving the request-scoped tier's trimmed evidence in practice.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions