Skip to content

perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner - #2329

Merged
thymikee merged 24 commits into
mainfrom
codex/2198-runner-demand
Sep 7, 2026
Merged

perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner#2329
thymikee merged 24 commits into
mainfrom
codex/2198-runner-demand

Conversation

@thymikee

@thymikee thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

Summary

Slice A of #2198. A local iOS Simulator open now derives how much the XCTest runner is needed from the runtime operations declared by the steps still ahead of it in the same batch, and observation stops waiting for runner readiness.

  • Runner demand. runBatch hands every step its place in the plan through the server-private internal request channel (never the wire). The daemon flattens the remaining steps' declared platformExecution uses; snapshot, diff, and find select their alternative from the step input the way their handlers do (selectUses on the descriptor). The Apple owner maps the operations through a record complete over the runtime-operation union. Observation-only plan → none (no runner started or retained). Unknown plan, including an open that ends its batch → possible (today's speculative prewarm, never awaited). Any runner-served operation → required (prewarm now; the first runner-dependent step awaits the same startup under the runner session lock with today's typed errors and deadlines).
  • open --relaunch no longer awaits runner readiness on a Simulator. The runner re-resolves a new app PID at its next command, so targetReset is sent only to a runner that is already alive and ready, or that an awaited prewarm just proved. Physical devices keep their relaunch lifecycle unchanged.
  • wait and read-only find observe runner-free. The Apple native find ports report found: false ("not proven here") instead of starting a runner on a Simulator without a ready session, so the poll consults the canonical AX-bridge tree (ADR 0019 §2). A ready runner keeps answering; provider-backed runners count as live through the runner provider seam.
  • Runner liveness is a provider question and means ready, not registered. A session record exists while xcodebuild is still connecting; treating it as live sent the relaunch reset into a starting runner and the failed reset invalidated the prewarm. Liveness now requires the session's readiness flag; scripted and request-scoped providers with no startup cost count as live.
  • Bounded bridge launch grace. Without the runner wait, the first snapshot after an open reached the AX bridge while the app was still becoming the primary foreground owner or registering its AX server, and the typed fallback then started the runner the plan had just avoided. A target younger than 10 s is re-read for a bounded grace measured from the first such failure: 5 s for a missing AX server, 1 s for an ownership miss so a launch-time system dialog still reaches the fallback quickly. Established targets get no grace; fallback stays typed and at most once.
  • The open response timing gains runnerDemand next to the existing prewarm fields (additive; ADR 0006 untouched). Vocabulary added to CONTEXT.md.

Also folds the two Apple native-find ports onto one admission helper, lifts the runner prewarm policy out of the open sequence (complexity gate), and shares one inert audio-probe fixture through @agent-device/host-kit/audio-probe-fixtures instead of two identical platform copies.

Scope: 41 files, ~1,370 gross lines. Over the 1,000-line budget because the liveness seam, the plan derivation, and the Simulator policy only make sense together with the regressions that fell out of live validation; I can split it into a three-PR stack (liveness seam → plan derivation → Simulator policy) if preferred.

Proxy parity, lease isolation, cancellation, version skew and controlled-RTT measurements remain slice B of #2198.

Production code by responsibility

Production lines vs main (tests, fixtures, scripts, docs and config excluded): 29 files, +918 / −114.

Responsibility Files Lines Why it exists
Runtime-operation vocabulary packages/contracts/src/runtime-operation-names.ts (new), platform-runtime-operations.ts (findRuntimeIntent), command-platform-execution.ts (selectUses) +128 The plan must name operations in a type the lifecycle contract can carry without importing the whole PlatformRuntimeOperations type (that import formed a 36-file type cycle, R9). selectUses lets a descriptor state which of its uses a concrete step needs (snapshot --custom-actions, find by action) instead of declaring the union.
Plan derivation packages/command-registry/src/planned-operations.ts (new), registry.ts (step-use selectors on snapshot, diff, find) +136 Flattens the required runtime operations of the remaining batch steps from the registry, the only place that knows them. Lives in the registry package because it is a derivation over every descriptor; approved over the new-entry closure ceiling for that reason.
Plan carrier src/core/batch.ts (BatchStepContext), src/daemon/handlers/session-batch.ts, src/daemon/execution-plan.ts (new), src/daemon/daemon-request.ts (internal.executionPlan), src/daemon/application-lifecycle-execution.ts, src/daemon/interaction/internal/find.ts +118 The batch runner tells each step what is still ahead; the daemon copies it into the server-private req.internal (stripped on the wire) and the lifecycle execution hands it to the platform as plannedOperations. No public flag, no new RPC field.
Simulator open policy packages/platform-apple/src/runner-demand.ts (new), open-policy.ts (new), lifecycle.ts, contracts/application-lifecycle-runtime.ts (plannedOperations, runnerDemand, postOpenObservation, hasLiveRunnerSession, releaseSpeculativeRunner) +302 Classifies operations as runner- or Simulator-served, derives none / possible / required, never awaits the prewarm on a Simulator with the bridge, notifies the runner only when one is live, releases a speculative runner on a proven observation-only plan, and lets the open settle on the bridge's own observability instead of a fixed sleep. Physical devices and tvOS keep their lifecycle.
Launch observability packages/platform-apple/src/snapshot-observability.ts (new), snapshot-route.ts, runtime-snapshot.ts +220 The open owns the launch transition: a bounded probe per failure code, never extended, replaces the grace window that used to live in the capture route. Native find admission and the discovery-pending case ask whether the runner can answer now instead of assuming it; with no live runner, observation stays on the bridge.
Runner liveness and speculative sessions packages/platform-apple/src/runner/runner-provider.ts, runner-client.ts, runner-session.ts, runner-session-types.ts, runner-lifecycle.ts, client.ts, core/runner-client.ts, runner-operations-facade.ts, src/platform-runtime-apple-application-tools.ts +112 Liveness is alive and ready, asked through the provider seam so scripted and request-scoped providers answer for themselves. A prewarm-started session is marked speculative until a command other than a readiness probe uses it; the release takes the session lock so a start still in flight is stopped when it completes.

Why a smaller design was ruled out:

  • Only skipping the relaunch wait (a one-line change) leaves every Simulator open prewarming a runner it may never use and keeps the first observation racing a fixed 300 ms settle; the corpus shows the cold-open gain comes from the bridge-observed settle plus the deferred wait together.
  • A public runner-demand flag was excluded by perf(ios): optimize observation lifecycle and prove proxy parity #2198 and would put the plan on the wire; deriving it from the batch keeps the client unchanged and the decision in the descriptor owner.
  • Deriving the plan from command names instead of descriptor uses would have counted preferred operations as required and mis-typed find (its default action is click); the review caught both, and the selector-based derivation is what fixed them.
  • Reading the runner registry directly for liveness broke every provider-scenario suite (scripted providers own no local session); the provider seam is the smallest thing that answers correctly for all providers.
  • Leaving the launch grace in the capture route kept a ps-parsed heuristic in the wrong owner and made the first post-open capture fall back on foreground-owner-unverified; moving it into the open as a bounded probe removed both.

Validation

Head: 325343d9a7; base for the measurements: 27a97ee619 (the merge-base of the measured head with main).

  • Unit: a never-resolving prewarm proves a Simulator open never awaits runner readiness; demand none / possible / required from the plan; a none open releases a speculative runner and leaves a served one; a release that arrives while the start is in flight stops it once it completes (deferred-start regression, red without the lock); relaunch resets the target only on a live runner; physical iOS and tvOS keep the awaited prewarm; the observation probe's windows, cap and deadline shrink; a pending app discovery keeps observation on the bridge while no runner is live, and takes the fallback at once when one is (red without the fix); native find admission by liveness; plan derivation counts required operations only, keeps find positionals, and classifies unknown names as unknown plans.
  • Gates: typecheck, fallow, layering (186), eager-closure and test-file size ratchets, CONTEXT budget green on the head; pnpm check:affected --run on the exact head posted as a comment when it completes.
  • Corpus (base 27a97ee vs head 7616ba2, dedicated Simulator, six screens; raw JSON on evidence/ios-snapshot, table in the PR comment): cold open −27 % to −40 %; relaunch −9 % to −12 % (system-surface −56 %); warm snapshot unchanged (xctest-stress −56 %); first interaction after a cold open unchanged on two screens, +0.3–0.5 s on the two fastest (the exposed tail of the runner start), with cold open + first click −18 % to −37 % on every measurable screen.
  • Harness-fixed cells (list, system-surface first interaction, head e729321): list 1372 → 1359 ms (−1 %), system-surface 990 → 1053 ms (+6 %, one 1.9 s sample), 0 failures on either leg; raw JSON on evidence/ios-snapshot (2198-slice-a-first-interaction-fixed-e729321dcc/).
  • Live release check on the dedicated Simulator (head, --debug): a lone open --relaunch (demand possible) starts the runner; an observation-only batch (open --relaunch, snapshot -i, wait text, demand none) releases it within a second; the same batch issued immediately after a lone open, while that start is still in flight, releases it once it completes; after a lone open and a served click, an observation-only batch leaves the runner alone. Two ios_runner_speculative_released records in the daemon diagnostics, one per release; every batch and click succeeded.
  • CI: every lane green except the Android smoke lane, which fails on main itself since dcd8b65 (last green on main b4ebd77; this branch's pre-merge head 7616ba2 was green on all four smoke lanes) and is not touched by this change.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.50 MB 4.51 MB +8.6 kB
Package (unpacked) 4.50 MB 4.51 MB +8.6 kB
Package (download) 1.33 MB 1.34 MB +2.5 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.1 ms 29.5 ms +0.4 ms
CLI --help 83.4 ms 85.2 ms +1.8 ms

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Not ready at bc68c7e.

  • The launch-grace deadline is set from the first failure. An AX-server failure followed by an ownership miss still gets the original 5 seconds instead of the ownership policy's 1 second. Recompute the allowed window without extending it, and add a regression for that transition. Cap polling to the remaining window before starting another acquisition.
  • The guidance-size check fails: CONTEXT.md is 12,354 bytes against a 12,000-byte limit. Trim it rather than raising the limit.
  • Bring in the managed-allocation repair from fix: repair main after the managed-allocation move landed under stale PRs #2328 and rerun the failing checks.
  • The two-run live comparison is useful, but the planned cold/warm/relaunch corpus and first-interaction comparison are still needed for this slice. Proxy parity can remain in the separately scoped follow-up.

Please clarify the runner-retention claim: demand none skips a new prewarm but does not release an existing speculative runner. Either prove the promised release through the existing lifecycle owner, or narrow the claim and explicitly settle that acceptance requirement.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Thermo-nuclear code quality review

This slice should not land in its current shape. The demand pipeline is the right idea, but the model still treats optimizations as requirements, misreads default find, and then papers over launch timing with a locale-fragile retry loop in the snapshot route. Those are structural problems, not polish.

Do not approve. Behavior on the happy observation batch is better; the implementation preserves incidental complexity and gets the policy model wrong in ways that will keep growing special cases.

1. Demand treats preferred operations as “must start a runner”

resolvePlannedRuntimeOperations unions required, preferred, and conditional operations, then Apple’s host table marks readTextAtPoint as runner. So batch [open, get] or batch [open, find] with action: 'getText' becomes demand required and starts XCTest.

That fights ADR 0019: preferred is a measured fast path, not a requirement. get / find getText still succeed from the capture tree; the native read is an optimization. #2198’s none is “proven observation-only,” not “no preferred runner facet exists.”

The tests pin the wrong model (getText must include readTextAtPoint). Count required operations for demand. Keep preferred/conditional for admission. Then get/wait/find getText stay none, and find type / click still go required because those legs are required.

That also makes the 80-row host table earn its keep: it classifies what a required operation actually executes, instead of laundering optimizations into prewarm.

2. selectFindStepUses does not mirror the handler, and remaining steps drop positionals

parseFindArgs defaults a missing action to click. The new selector does the opposite:

if (action === undefined || action === 'wait') return selectorUsesByIntent['capture-only'];

{ command: 'find' } is therefore planned as observation-only and executed as a tap. The planned-operations test asserts that hole.

runBatch then copies only command + input into remainingSteps and drops positionals. A daemon step whose action lives in positionals is planned as capture-only even when the running step will click.

Fail closed: unknown or missing find action → full findRuntimePlanUses (already the click/fill branch). Or parse the same way the handler does, including positionals. Do not invent a third action→intent map next to find.ts and selectorCaptureIntent().

3. Launch grace is a second “was this just launched?” source of truth, in the wrong layer

snapshot-route.ts went 287 → 345 (past the 300-line module target). The last commit claims the grace was lifted out of the route; it is still in the same file, as a poll loop keyed on Date.parse(target.processStartTime) from ps -o lstart= text.

Problems that one design change would delete:

  • Wrong layer. Youth is inferred from process display text. Open already owns settle (POST_OPEN_SETTLE_MS). Either stamp a launch timestamp on the target/session, or wait for AX readiness at open. Do not reconstruct “just launched” in the snapshot router.
  • Locale-fragile. Unparseable lstart is treated as established, so grace silently becomes zero off en/Date.parse-friendly machines.
  • Deadline from the first code. An AX-server miss (5s) then an ownership miss still keeps the 5s window. A system dialog must hit fallback in 1s. Recompute the remaining window per code and cap sleep to that remainder. This is a symptom of stuffing policy into a retry loop.

A bounded post-open AX wait, or a typed launch generation on the target, deletes the map, the poll, and the ps parse from the route. Fallback stays one-shot and typed.

4. Policy object is only half a lifecycle decision

RunnerPrewarmPolicy answers start/await. It does not answer release. #2198 and CONTEXT.md say demand none does not start or retain a runner. An existing speculative session survives an observation-only open.

That is not a later nice-to-have. Either stop/idle-stop through the existing lifecycle owner when demand is none, or narrow the vocabulary to “we skip a new prewarm” and drop the retain claim. A boolean shouldPrewarmRunner will keep accumulating hasLiveRunnerSession branches in relaunch notify, find admission, and snapshot fallback.

5. Type / boundary leaks

OpenApplicationPlan.operations: readonly string[] plus the Apple-side casts:

const operations = plan.operations as readonly string[];
const hosts = APPLE_SIMULATOR_OPERATION_HOSTS as Readonly<
  Record<string, AppleSimulatorOperationHost | undefined>
>;

The complete Record<RuntimeOperationKey<...>, ...> is the good part of this PR. The casts throw that exhaustiveness away at the only call site. Type the plan as readonly RuntimeOperationKey<PlatformRuntimeOperations>[] (or pass uses, not names). Unknown strings staying required can remain a runtime fallback without unsafely widening the table.

hasLiveSession? omit-means-live is a silent default on a liveness seam that is easy to get wrong (registered ≠ ready). Scripted providers should say hasLiveSession: () => true explicitly. Optional “no startup cost” is the kind of fallback that sent targetReset into a connecting session last time.

plan on OpenApplicationInput is Apple batch policy on the shared lifecycle contract. Daemon-derived, Apple-consumed is the right ADR 0019 split; the carrier should be a typed operations list (or already-resolved demand) on Apple execution, not an untyped plan? every platform open sees.

selectUses?: (input: Record<string, unknown>) => ... is the right registry extension for snapshot/diff/find. Fold it into the existing uses variant instead of a second sameKeys branch. Keep the selector next to resolveSnapshotRuntimePlan / find’s real action map so it cannot drift.

6. Size / spaghetti (secondary)

  • lifecycle.ts 447 → 490. Extract before the next policy flag; resolveRunnerPrewarmPolicy should not stay in the open sequence file if release/retain joins it.
  • Registry was already >1k; the three selectUses attachments are fine if the selector is the real handler function.
  • admitAppleNativeFind is a real cleanup. simulatorRunnerNotLive as a negative predicate is not; name the positive admission (runnerCanAnswerNow).
  • Fixture share for the audio probe is the one unambiguous simplification. Keep it.

What is already good and should stay: server-private internal.executionPlan (ADR 0006 untouched); Apple-owned operation→host exhaustiveness; Simulator never awaiting prewarm; liveness = alive and ready; physical-device path unchanged; find ports returning found: false instead of starting a runner.

Bar to approve: required-only demand; find selection that matches default-click and still sees positionals; launch timing owned by open/target rather than a ps-parsed retry loop; retain/release actually implemented or the claim removed; plan typed through without string casts. Until then this is a working observation fast path with a policy model that will keep growing branches.

…ion from awaiting the runner

Local Simulator opens now decide how much the XCTest runner is needed from the
runtime operations declared by the steps still ahead in the same batch: an
observation-only plan starts no runner, an unknown plan keeps the speculative
prewarm without ever awaiting it, and a plan with an interaction prepares
readiness for that step. open --relaunch no longer waits for runner readiness
on a Simulator and resets the runner target only when a session is already
alive. The Apple find ports report not-proven instead of starting a runner on
a Simulator without a live session, so wait and read-only find observe through
the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged.

The plan travels through the server-private internal request channel, never
the wire; the Apple owner maps declared operations to a runner demand through a
record complete over the runtime operation union.

Refs #2198
…runtime fixtures

The Apple and Android runtime fixtures carried identical audio-probe doubles;
host-kit now owns the one copy and both fixtures import it. Also folds the two
Apple native-find ports onto one admission helper and lifts the Simulator
runner prewarm policy out of the open sequence, keeping both under the
complexity gate.
The find ports and the relaunch target reset asked the local session registry
whether a runner was alive, which misreads scripted and request-scoped runner
providers as absent. Liveness is now a provider question: the local provider
consults its session registry, a provider without startup cost counts as live,
and an awaited prewarm proves liveness without asking.
…targets a bounded bridge grace

A snapshot, diff, or find step now selects the runtime uses its structured input
reaches, the way its handler does, so a plain snapshot no longer counts the
custom-actions alternative and an observation-only batch resolves runner demand
none. The descriptor declares the selector next to its alternatives; the daemon
plan derivation honors it and keeps the union for every other command.

Without the runner wait, the first snapshot after an open reached the AX bridge
while the app was still becoming the primary foreground owner or registering
its accessibility server, and the typed fallback then started the runner the
plan had just avoided. A target younger than ten seconds is re-read for a
bounded grace measured from the first such failure: five seconds for a missing
AX server, one second for an ownership miss so a launch-time system dialog
still reaches the fallback quickly. Established targets get no grace.
… answered

A session record exists while xcodebuild is still connecting, so an alive
child pid is not a runner that can answer. Treating it as live sent the
relaunch target reset into a starting runner, queued behind its connection
retries, and the failed reset invalidated the very session the prewarm was
building. Liveness now also requires the session's readiness flag, which the
first successful runner response sets.
… runner-session tests within the size ratchet

A Simulator open schedules a best-effort runner prewarm that outlives its
request. The replay test-runner suite opened a Simulator with the real Apple
tools, so the prewarm's deferred import resolved after the file finished and
spawned into whichever file the worker ran next, where the hermetic signal
guard failed an unrelated test.
…steps the way their handlers do

Runner demand now counts a command's required operations only: a preferred or
conditional operation is a measured fast path the command succeeds without, so
get, wait, and read-only find stay observation-only. The step selectors for
snapshot, diff, and find live next to the registry and read the daemon step
exactly as the handlers do: the daemon flag for custom actions, and find's
positionals through the same parser, where a missing action is a click and an
unparseable step keeps every declared alternative. The handler and the selector
share one action-to-intent map. The batch runner hands each step its remaining
steps in handler shape, and the derived operations reach the platform as a
typed list on the lifecycle execution instead of an untyped plan on every open.
…nd make runner liveness explicit

The snapshot route no longer infers a launch from process start text and
retries inside its own capture. Open owns launch timing instead: a local
Simulator open asks the AX bridge whether the launched app is observable,
bounded by per-code windows measured from the first typed launch-transition
failure and never extended, so an ownership miss seen after an AX-server miss
shrinks the deadline to the ownership window and a launch-time system dialog
still reaches the typed fallback quickly. Any other device, or a bridge that
cannot answer, keeps the fixed settle. The open response reports what it
learned.

Every runner provider now states whether it can answer without a startup wait;
a bare executor answers directly by construction and scripted providers say so.
The runner prewarm policy and the observation settle move out of the open
sequence into their own module, and the native find admission is named for what
it admits.
…budget

The enumeration and the no-public-flag rule live on the contract type that
owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose
words that carried no meaning.
…operations union

The lifecycle execution carries the operations a plan requires, but typing
that list with the operations union closed a 36-file type cycle: the
operations types depend on the lifecycle types. The vocabulary now lives as a
const list below both, proven equal to the union by a type test, so the plan
is typed end to end, the Apple host table indexes it without casts, and the
daemon narrows descriptor names through a guard instead of a cast.
Every Apple tool port loads the runner operations through the one memoized
loader (#2314): a port that opens its own dynamic import can resolve the
unmocked module while a test's mock factory is still loading and let a real
local runner escape. The liveness port now uses the loader like its siblings;
the facade members consumed only through the loader are declared to fallow,
and the plan resolver reads one step per helper to stay under the complexity
threshold.
The launch observation, the runner-free find admission, and the relaunch
policy apply only where the host AX bridge exists: iOS Simulators. A tvOS
Simulator keeps its awaited prewarm and asks for no observation, which the
tvOS provider scenario now pins.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Addressed both reviews at d654cc3776 (rebased on main with the #2328 repair).

Demand model

  • Demand counts required operations only; preferred and conditional stay admission-only. get, wait, find … get text now resolve none; find type/focus and touches stay required. Test pins the ADR 0019 reading (planned-operations.test.ts).
  • find is planned from its positionals through the handler's own parser (checkFindArgs): a missing action is a click, and an unparseable step keeps every declared alternative. The handler and the selector share one action→intent map (findRuntimeIntent); find.ts calls it now. Batch steps carry positionals and flags in handler shape; the snapshot selector reads the daemon flag (snapshotCustomActions), not a structured field the daemon never sees.
  • The selectors moved out of contracts to src/core/command-descriptor/step-use-selectors.ts (they need the selectors package). The validator has one uses branch that admits an optional selectUses.

Launch timing

  • The route no longer infers "just launched" from ps text or retries inside capture; that code is gone (snapshot-route.ts 345 → 294 lines). Open owns it: a local-Simulator open asks the bridge whether the launched app is observable (snapshot-observability.ts), with per-code windows measured from the first typed launch-transition failure and never extended. An ownership miss after an AX-server miss shrinks the deadline to the ownership window (regression test), the last poll is capped to the remaining window, and any other failure ends the wait at once. Devices without a bridge, or a bridge that cannot answer, keep the fixed settle. The open response reports postOpenObservation.

Retain/release

  • Narrowed rather than promised: none starts no runner for this plan; an already-live runner stays under the existing idle-stop policy. The contract doc and CONTEXT.md say exactly that. Releasing a speculative runner from the open seam would need the lifecycle owner to know the batch has ended; that is a separate decision, and perf(ios): optimize observation lifecycle and prove proxy parity #2198's acceptance line should read the narrowed wording.

Types and seams

  • The plan reaches the platform as plannedOperations: readonly RuntimeOperationKey<PlatformRuntimeOperations>[] on ApplicationLifecycleExecution; OpenApplicationInput.plan is gone. The Apple table is indexed without casts. One documented cast remains where descriptor uses (built by defineUse, which admits only operation keys) become keys.
  • hasLiveSession is required on every runner provider; a bare executor answers directly by construction (stated in normalizeAppleRunnerProvider), scripted providers say () => true explicitly.
  • resolveRunnerPrewarmPolicy and the observation settle live in open-policy.ts (lifecycle.ts 490 → 449). simulatorRunnerNotLive became runnerCanAnswerNow.

CI

Evidence

@thymikee
thymikee force-pushed the codex/2198-runner-demand branch from bc68c7e to d654cc3 Compare September 6, 2026 08:41
…harness

An open that defers runner readiness moves its cost to the first
runner-dependent command. The cell starts each sample like cold, opens the
fixture untimed, then times the first press that follows (the deep-link
confirmation when the launch URL raises it, otherwise the screen anchor).
…de type

The open response carries no tree and regular snapshots publish the node type,
so the confirmation iOS raises for a launch URL was never seen on this runtime
and every deep-linked cell failed its anchor check.
The eager-closure ratchet counts every module the registry loads; the
selectors need nothing the registry does not already import, so they live
beside find's recording-effect reader instead of adding a module to every
entry that loads the registry.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

The earlier demand-selection, find parsing, launch-ownership and typing findings are addressed at 7616ba2, and CI is green. Two acceptance items remain:

  • The new wording keeps an existing runner under idle-stop, but perf(ios): optimize observation lifecycle and prove proxy parity #2198 still requires observation-only plans not to retain one. Please settle that scope change explicitly in the issue, or implement release through the existing lifecycle owner.
  • Please post the completed cold/warm/relaunch and first-runner-interaction comparison, with the tested commits and raw results. The latest update still says it is running; the PR body records the older head.

Holding readiness until those are resolved.

…ervation-only

#2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a
prewarm started that no command has used yet is speculative: the session records that mark at
creation, the first command that is not a readiness probe clears it, and a Simulator open whose
plan is proven observation-only asks the runner owner to release a speculative session in the
background, so the observation path never waits for a runner to stop either. A runner that has
served a command is the session's working runner and stays under the existing idle-stop policy,
so a mixed workload does not pay a cold runner start at every observation-only open.

The release goes through the runner provider seam: the local provider stops its own speculative
session; a provider that never starts speculative work omits the operation and releases nothing.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

CI is green on 7616ba2, but this head now conflicts with main. Resolve the conflict before landing the stack. The two acceptance items from the previous review are still open: publish the cold/warm/relaunch and first-interaction results, and settle the runner-retention scope in #2198. No new implementation review is needed for this unchanged head.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Conflict with main resolved by merging main at b36d2ce (two list unions: the host-kit package.json exports and the pinned host-kit export list in scripts/layering/package-boundaries.test.ts). Typecheck, the package-boundaries test, and the affected Apple/plan suites pass on the merged head.

Runner retention: implemented rather than waived, at 9189275. A runner a prewarm started that no command has used is speculative; a Simulator open whose plan is proven observation-only releases it through the runner owner, in the background. A runner that has served a command stays under the existing idle-stop owner. The explicit scope decision and its rationale are on #2198.

Benchmarks: the base legs are complete at 27a97ee (cold, first-interaction, warm, relaunch, six screens); the head legs are running now against dist built from 7616ba2 on the dedicated simulator. The full comparison, tested commits, and raw JSON land here as soon as the last leg finishes. Two first-interaction cells (list, system-surface) are harness-invalid on both legs (the anchor text names two actionable elements, rejected as AMBIGUOUS_MATCH by design); they get re-run with an unambiguous target and reported separately.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

The new release path misses a pending prewarm: a possible open can still be blocked before runnerSessions.set, then a later none open sees no session and its release returns false; when startup completes, that speculative runner remains retained. Coordinate cleanup with the runner owner’s pending-start state and add a deferred-start regression. The benchmark/corpus evidence is also still pending.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Corpus: base vs head, cold / first-interaction / warm / relaunch

Tested commits: base production code 27a97ee, head production code 7616ba2 (the dist each leg ran was built from that commit). Harness scripts/ios-snapshot-benchmark at e319193 for both legs. Simulator ad-bench-2198 (iOS 26.2), dedicated to this corpus, CI fixture app; host MacBook Pro M4 Pro; cold and first-interaction at 10 samples per cell, warm and relaunch at 20. Wall-clock medians in ms, fresh-process CLI.

Raw results and provenance: 2198-slice-a-7616ba222d/ on evidence/ios-snapshot (base cold+first, base warm+relaunch, head cold+first, head warm+relaunch, SHA256SUMS). One provenance note is in the README: the head cold+first file stamps revision.commit as 9189275 because that commit was checked out when the leg started; its dist was built from 7616ba2 and the two differ only in source that was not rebuilt into it.

State Screen Exec RTT N Base wall med Head wall med Δ med Base p95 Head p95 Base daemon med Head daemon med Fail base/head
cold alert fresh-process-cli 10/10 8016 5610 -2406 (-30%) 11834 6189 4242 4326 0/0
cold list fresh-process-cli 10/10 9080 5772 -3308 (-36%) 16489 6390 4944 4454 0/0
cold nested-scroll fresh-process-cli 10/10 9556 5730 -3826 (-40%) 194820 6972 5182 4478 0/0
cold quiet fresh-process-cli 10/10 7619 5600 -2019 (-27%) 10669 5833 3902 4308 0/0
cold system-surface fresh-process-cli 10/10 8446 5598 -2848 (-34%) 9616 6067 4422 4147 0/0
cold xctest-stress fresh-process-cli 10/10 8116 5872 -2244 (-28%) 9452 8337 4504 4552 0/0
first-interaction alert fresh-process-cli 10/10 898 897 -1 (-0%) 970 1446 0/0
first-interaction list fresh-process-cli –/– 10/10
first-interaction nested-scroll fresh-process-cli 10/10 920 918 -2 (-0%) 983 1364 0/0
first-interaction quiet fresh-process-cli 10/10 845 1355 +510 (+60%) 999 1462 0/0
first-interaction system-surface fresh-process-cli –/– 10/10
first-interaction xctest-stress fresh-process-cli 10/10 1226 1549 +323 (+26%) 1280 1703 0/0
relaunch alert fresh-process-cli 20/20 4059 3637 -423 (-10%) 7347 3773 2050 2723 0/0
relaunch list fresh-process-cli 20/20 3963 3687 -275 (-7%) 4321 3845 2024 2792 0/0
relaunch nested-scroll fresh-process-cli 20/20 4033 3673 -360 (-9%) 4098 3820 2019 2771 0/0
relaunch quiet fresh-process-cli 20/20 4129 3696 -434 (-11%) 7607 3811 2021 2799 0/0
relaunch system-surface fresh-process-cli 20/20 7584 3303 -4282 (-56%) 10201 3403 2224 2379 0/0
relaunch xctest-stress fresh-process-cli 20/20 4175 3672 -502 (-12%) 5156 3815 2056 2771 0/0
warm alert fresh-process-cli 20/20 145 149 +4 (+3%) 160 159 59 61 0/0
warm list fresh-process-cli 20/20 218 217 -1 (-0%) 233 233 132 133 0/0
warm nested-scroll fresh-process-cli 20/20 122 120 -3 (-2%) 138 146 35 33 0/0
warm quiet fresh-process-cli 20/20 119 112 -6 (-5%) 129 131 28 27 0/0
warm system-surface fresh-process-cli 20/20 147 146 -1 (-1%) 396 152 56 57 0/0
warm xctest-stress fresh-process-cli 20/20 323 142 -182 (-56%) 868 152 109 57 0/0

What the cells say

  • Cold open (daemon and runner cold, app terminated): −27% to −40% on every screen. Head returns when the bridge shows the app; base waited for the runner inside the open.
  • Relaunch (warm daemon, live runner): −9% to −12% on five screens; system-surface −56% (base 7.6 s → 3.3 s). Head no longer awaits the target reset/prewarm on the live runner.
  • Warm snapshot: unchanged within noise on five screens; xctest-stress −56% (323 → 142 ms, base p95 868 → 152).
  • First interaction after a cold open (the first click, timed alone): alert and nested-scroll unchanged; quiet +510 ms and xctest-stress +323 ms. That is the tail of the runner start that the open no longer hides: head samples are bimodal (quiet 800–831 ms when the runner was already ready, 1333–1462 ms when the click waited). The flow that matters is the open plus that click:
Screen Base cold open + first click Head cold open + first click Δ
quiet 7619 + 845 = 8465 5600 + 1355 = 6955 −1509 (−18%)
nested-scroll 9556 + 920 = 10476 5730 + 918 = 6648 −3828 (−37%)
alert 8016 + 898 = 8914 5610 + 897 = 6507 −2407 (−27%)
xctest-stress 8116 + 1226 = 9342 5872 + 1549 = 7421 −1921 (−21%)

(Both cells run under the same preconditions; the sum pairs their medians.)

  • Harness-invalid cells: first-interaction on list and system-surface fail 10/10 on both legs with AMBIGUOUS_MATCH (the harness pressed the anchor text, which names the native tab and the screen title; the CLI refuses that by design). Those two cells are re-run with an unambiguous target and posted separately; nothing else in the corpus is affected.
  • Failures elsewhere: 0 on both legs. Base cold nested-scroll carries one 195 s outlier sample (p95), flagged by the Tukey rule.

Main moved the command descriptor registry into @agent-device/command-registry (#2348) and split
src/daemon/types.ts (#2346): the planned-operations module and its test follow the registry into
the package (exported as ./planned-operations), the executionPlan carrier moves to
daemon-request.ts, and the batch step context lands on the new BatchInvoke signature.
…creens

The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS
Settings screens names two actionable elements (the native tab and the screen title); the CLI
refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything.
Each such screen now names the element the cell presses.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

The corpus now shows useful cold-open gains. The pending-prewarm release issue in the earlier comment still applies at adec882. Please rerun the two invalid first-interaction cells with the corrected selectors and document the additional production code by responsibility, including why a smaller design was ruled out. Android smoke is still failing; iOS smoke is running.

…ng and no runner is live

#2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback
past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a
relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll
budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no
live runner now stays on the single-flight discovery, one wait slice at a time, until the
discovery's own deadline or the request signal ends it; a runner that is already live still takes
the fallback at once, the cheaper route #2331 chose.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

CI after the main merge: the Android smoke lane fails on main itself at the same live-emulator E2E (main bd08e6e, wait for landscape), so that one is not this branch's. The iOS smoke lane was ours, and it is fixed at e729321.

Root cause: two merged changes met in wait right after open --relaunch. #2331 bounds one capture's wait for the Simulator app discovery (1.5 s) and takes the XCTest fallback past it; this branch stops the relaunch from awaiting the runner. On a loaded CI host the discovery after a relaunch outlasted the slice, the capture fell back to XCTest, XCTest waited for the still-starting runner, and the wait spent its poll budget on that start (live-snapshot-depth-frontier: "wait timed out for text: Agent Device Tester"). Main does not hit it because its relaunch had already paid the runner start.

Fix: a capture with no live runner stays on the single-flight discovery, one wait slice at a time, until the discovery's own 15 s deadline or the request signal ends it; a runner that is already live still takes the fallback at once (the cheaper route #2331 chose). Pinned in snapshot-route.test.ts: the discovery-pending test now states it has a live runner, and a new test with no live runner proves the capture waits through three slices and serves the bridge without calling the fallback (red without the fix). No other behaviour changes.

@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

The new e729321 discovery fix addresses the iOS wait regression without adding another discovery path. The pending-prewarm release race and the two corrected first-interaction measurements are still outstanding. Android smoke has now failed on this revision; other checks are still running.

… still in flight

A `possible` open's prewarm registers its session only when the start completes, so a `none` open
that released in that window found nothing and the runner it meant to release survived as a
retained speculative session. The release now takes the runner session lock: it queues behind the
in-flight start, sees the registered speculative session, and stops it; a start a command asked for
is left alone. Two deferred-start regressions pin both outcomes.
@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Round at 325343d:

  • Pending-prewarm release race: fixed at 325343d. releaseSpeculativeIosRunnerSession now takes the runner session lock, so a release that arrives while a possible open's start is still in flight queues behind that start, sees the registered speculative session, and stops it; a start a command asked for is left alone. Two deferred-start regressions pin both outcomes (runner-session-speculative.test.ts, the first red without the lock). Reproduced live first: on the previous build a none batch issued right after a lone open left the runner retained; on this build the same sequence releases it once the start completes (ios_runner_speculative_released in the daemon record), and a runner that has served a click survives the next observation-only open.
  • Harness-fixed first-interaction cells (base 27a97ee vs head e729321, same Simulator and host as the corpus, 10 samples each, load 9–11): list 1372 → 1359 ms (−1 %), system-surface 990 → 1053 ms (+6 %, one 1.9 s sample), 0 failures. Raw JSON and provenance: 2198-slice-a-first-interaction-fixed-e729321dcc/ on evidence/ios-snapshot. The harness fix itself is adec882 (interactionTarget per screen).
  • Production code by responsibility, and why a smaller design was ruled out: added to the PR body (six responsibilities with files and line counts against main, five smaller designs and what each would have lost), together with a rewritten Validation section for this head.
  • Android smoke: fails on main itself since dcd8b65 (last green on main b4ebd77; this branch's pre-merge head 7616ba2 was green on all four smoke lanes) and this change does not touch Android; it needs a fix on main. Every other lane is green on this head.

pnpm check:affected --run on this exact head is running now and its result follows as a comment.

@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

pnpm check:affected --run on 325343d: every stage green. The first pass of the vitest-related stage (587 files, 4,877 tests, --maxWorkers=4) had two failures that are host-contention artifacts, not code: a 5 s test timeout in runner-client.test.ts (ensureXctestrunArtifact aborts only the disconnected request build…) and an ENOTEMPTY while an afterEach removed its temp dir in session-device-claims.test.ts. Both files pass alone (61 tests), and the exact stage command re-run passes in full (587 files, 4,877 tests). Nothing else in the gate moved.

* origin/main:
  test(android): reveal smoke canaries by visibility (#2369)
  refactor(daemon): extract the session event journal into a workspace package (#2361)
  refactor(daemon): relocate the daemon client out of src/daemon (#2360)
@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Merged the current main at a23f495 (it carries #2369, the Android smoke canary fix, so the remaining red lane should clear on this head). No conflicts; typecheck, fallow, and the ratchets are green locally.

@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

The pending-prewarm release race is fixed at a23f495. Release now waits on the startup owner’s lock, and the deferred-start regression fails without that ordering while preserving a demanded runner. The recorded live release and benchmark evidence address the earlier gap. Ready for human review; remaining CI status is separate.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 7, 2026
@thymikee
thymikee merged commit d26b078 into main Sep 7, 2026
19 checks passed
@thymikee
thymikee deleted the codex/2198-runner-demand branch September 7, 2026 08:13
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-07 08:13 UTC

thymikee added a commit that referenced this pull request Sep 7, 2026
…erge (#2375)

`no APPROVED_OVER_CEILING row is stale` reads the introduced-entry set, which
is derived from `git merge-base origin/main HEAD`. On a push to main the
merge-base IS the head, so nothing is first-introduced and every approval row
reads as stale whatever its real state. That is exactly the shape of the
approving PR's own merge commit: #2329 added the
`packages/command-registry/src/planned-operations.ts` row to merge, and the
merge that followed it called the row dead. Coverage has been red on main
since (run 34099687663), and every branch cut from main after it inherits the
same failure.

- `staleApprovalRows` makes the verdict a named rule and defers it when the
  merge-base is the head, where no row is readable at all. Enforcement is not
  lost: a row that outlives its PR is still reported on the first branch whose
  merge-base could have read it, and the rule is pinned in both directions.
- The `planned-operations.ts` row goes, which is what the rule asks for now
  that main carries the entry: its closure (74) is governed by the no-growth
  rule from here on, not by the domain-facade ceiling.


Claude-Session: https://claude.ai/code/session_01SfQqXj7JKQVgBA8eg9SMVB

Co-authored-by: Claude <noreply@anthropic.com>
thymikee added a commit that referenced this pull request Sep 7, 2026
The APPROVED_OVER_CEILING row for packages/command-registry/src/planned-operations.ts
was consumed by #2329. Now that main carries the entry, the no-growth rule governs it
and the row can no longer change any verdict, so the "no row is stale" probe fails on
main and on every PR based on it. Remove the row, as the probe instructs.
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.

1 participant