Skip to content

fix(ios): grant the text-entry commit wait time against progress - #2035

Open
thymikee wants to merge 4 commits into
mainfrom
claude/1874-text-entry-commit-budget
Open

fix(ios): grant the text-entry commit wait time against progress#2035
thymikee wants to merge 4 commits into
mainfrom
claude/1874-text-entry-commit-budget

Conversation

@thymikee

@thymikee thymikee commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

type and fill on iOS stop reporting TEXT_INPUT_COMMIT_NOT_OBSERVED over text that is still
landing. The synthesized commit wait used a flat 3s deadline, which cannot tell a throttled
simulator input pipeline (characters keep arriving, slowly) from a wedged one (nothing arrives) —
so it condemned both at the same instant. That is what turned a simulator-load episode into a red
Smoke Tests (iOS simulator) / targeted-XCTest lane on branches touching no iOS code.

  • The commit wait now grants time against progress (SynthesizedCommitBudget). The observed
    value's expected-prefix length — the same length-only evidence logCommitCadence already emits —
    decides: while it grows, the wait continues, up to a 10s ceiling. A pipeline making no progress
    expires at exactly the 3s the flat deadline used, so nothing that fails today starts passing
    merely by waiting longer, and a wedge is condemned no later than before.
  • Text-entry readiness stops guessing from a wall clock. waitForTextEntryReadiness's
    hardware-keyboard fallback returned a possibly-unfocused element after 0.35s of "no software
    keyboard seen". It now returns only when the target element itself confirms keyboard focus, and
    re-arms that window otherwise; unconfirmed, the wait runs its normal readiness timeout — what it
    would have spent with no fallback at all.
  • The keyboard-hidden precondition of testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden
    is now XCTSkipIf, not XCTAssertFalse.
    An environment flip must not read as a product
    regression on an unrelated PR.

Closes #1874.

Two corrections to the issue's diagnosis

Neither observed failure reaches waitForTextEntryReadiness:407. Tracing both paths:

  • testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden types through the tap witness, and
    rememberedTextEntryTarget() returns before readiness is ever consulted.
  • smoke:form-inputfill full name never reaches readiness either: it fails inside
    runSynthesizedReplacementRoute, which shouldUseSynthesizedFirstResponderReplacement gates on
    hasResolvedElement == false — the penalized coordinate route builds that target directly in
    executeTypeCommand, bypassing focusTextInputForTextEntry entirely.

And the smoke half is not the deadline either — it is the fixture. This PR's own iOS lane
reproduced it (run 32889322172),
and the trace settles it:

[DEBUG-1874] wait start expectedLen=12 route=replacement     19:35:00.652
   (no poll lines at all)
[DEBUG-1874] wait outcome=notObserved elapsedMs=3608 route=replacement   19:35:04.260

The wait never polled. It returned from the textMatchesPlaceholder guard, which refuses before
polling because an empty text field renders its placeholder as its accessibility value, so a match
cannot prove a commit. expectedLen=12 is "Ada Lovelace" — and field-name's placeholder was
"Ada Lovelace". field-email had the same collision with ada@example.com: twelve fills across
eight files, every checkout-form flow in the repository. The 3.6s is just the AX resolution for the
placeholder read that precedes the guard.

It looked intermittent because the synthesized-replacement route is gated on
xCTestChannelPenalized — it fires when the host is loaded. That also explains the datapoint the
issue found most puzzling: rerun_failed_jobs on the same commit reproducing identically. A
deterministic guard on a load-gated route looks exactly like a sticky per-device flake.

The collision additionally made the read-back assertions vacuous — assertJsonContains(name, 'Ada Lovelace') is satisfied by an empty field rendering the placeholder — so fixing it strengthens the
smoke rather than just quieting it. Fixed in the fixture, not in the values, because frozen
replay-compat corpora carry the same fills; fixture-fill-placeholder-collision.test.ts fails on
any repository fill whose value equals its field's placeholder.

So the three changes address three different things: the budget is what the targeted XCTest lane
needs, the fixture is what the smoke lane needs, and the readiness fix closes the latent
sensitivity the issue describes without being what fired in either.

Pinning the simulator keyboard preference would have been a no-op, so this PR does not do it.
Measured on a dedicated simulator, headless simctl boot, iOS 26.2, then deleted:

per-device ConnectHardwareKeyboard Settings search field, after tap
true (hardware keyboard "connected") full software keyboard
false full software keyboard

com.apple.iphonesimulator is Simulator.app's preference domain; with no Simulator.app running,
CoreSimulator does not consult it. Writing it would have added a step, a guard test and a green
assertion that proved only that defaults write ran. The useful half of that finding is the
reassuring one: a headless lane has no hardware keyboard to inherit, so the software keyboard
always shows and the ambient state the issue names is not a variable in these lanes at all — which
independently agrees with the 2026-08-21 diagnosis comment.

Validation

Local, iPhone 17 Pro / iOS 26.2 simulator, arm64 slice. The Swift tree is byte-identical to the
validated one — main moved under this branch mid-work (#2021) and the rebase touched nothing
under apple/runner/, and the static gates were re-run on ba7e03f885 after it:

  • The full ios.yml PR -only-testing: list: 75 passed, 0 failures.
  • Red-first for the budget, through the shipped waits: reverting SynthesizedCommitBudget.record
    to a no-op turns testCommitWaitOutlivesTheFlatDeadlineWhileTheExpectedPrefixGrows and
    testCommitWaitCeilingStopsAnIndefinitelyThrottledPipeline red, while
    testCommitWaitCondemnsAFrozenPipelineAtTheStallBudget and
    testCommitWaitTreatsARetreatingValueAsNoProgress stay green — they encode the behavior that
    must not move.
  • testHardwareKeyboardResponderConfirmsItsOwnKeyboardFocus was written red first and caught a
    wrong implementation: XCUIElement.descendants(matching:) does not include the receiver, so
    an element-rooted focus query answers 0 for a leaf text field. Instrumented on-device
    (selfDescendants=0, appFocused=1 naming the tapped field at keyboardVisible=0) and rebuilt
    against the app-wide predicate with an identity check. That XCTest behavior is now pinned by the
    test rather than assumed.
  • The keyboard A/B above; probe device deleted and com.apple.iphonesimulator restored from a
    pre-probe export afterwards, agent-device sessions closed.
  • The placeholder collision was found by reading this PR's own failed smoke lane, and the guard
    that now covers it was planted-red: before the fixture change it lists all twelve collisions.

Not verified locally: the macOS host XCTest lane, blocked on this host by the known
Accessibility/Automation permission gap. The budget tests are platform-neutral and were executed on
the simulator instead, where the same #if AGENT_DEVICE_RUNNER_UNIT_TESTS bundle runs them. CI on
the head is the authority for the host and smoke lanes.

Unrelated pre-existing red on main at the same commit, not introduced here:
cli-help-topics.test.ts / cli-help.test.ts fail because the workflow help card is 9003 bytes
against a 9000-byte cap.

Design notes and residual risk

  • The budget lives inside the wait. awaitSynthesizedCommitOutcome and its replacement
    counterpart hold it as a local var and advance it from the same observation the progress check
    already reads, with the clock injected alongside the existing observation and pacing seams. An
    earlier revision made it a detached object shared between two escaping closures and guarded that
    sharing with a TypeScript parser; the guard was evidence the seam was wrong, so both are gone.
    What replaced them is four sequence tests that drive the real waits.
  • A failing fill can now take up to 10s instead of 3s when the value keeps converging without
    ever matching (a formatter rewriting input, say). Bounded well inside the daemon's 45s
    per-command budget. A dropped-character corruption still fails at ~3s, because its prefix freezes.
  • fill still cannot verify a value equal to the field's placeholder, and that asymmetry is
    route-dependent: the ordinary xctest-element route reads the raw value back and passes, while
    the penalized route refuses. Both are right on the evidence each has, but the same command can
    therefore report differently under load. Worth its own issue against fix(ios): report an unobserved text commit instead of a partial success #1924's three-state
    PlaceholderCommitEvidence model; not expanded into here.
  • This reduces exposure rather than eliminating it. The 40-second episode traced in the
    2026-08-21 comment would still exhaust the ceiling and report honestly. Why the simulator input
    pipeline throttles at all is unchanged by this PR; the [DEBUG-1874] cadence traces stay in place
    for it.

Size: the growth is Apple runner source, because the whole AgentDeviceRunnerUITests bundle
ships as source; JS shrinks. SynthesizedCommitBudget keeps its own file only to hold
RunnerTests+SynthesizedTextEntry.swift inside the 500-line extraction rule — folding it back
would save nothing packed. The review rework removed the detached test file and the TypeScript
guard, so the final figure is below what the last size report measured.

8 files: the iOS text-entry path, the form fixture's two placeholders, and one guard. No docs change:
TEXT_INPUT_COMMIT_NOT_OBSERVED's meaning and recovery advice in website/docs/docs/commands.md
are unchanged — only how long the runner looks before reporting it.

The synthesized commit wait used a flat 3s deadline, which cannot tell a
throttled simulator input pipeline (characters keep landing, slowly) from a
wedged one (nothing lands) — it condemned both at the same instant and reported
TEXT_INPUT_COMMIT_NOT_OBSERVED over a `type`/`fill` that was still working, on
branches touching no iOS code.

SynthesizedCommitBudget grants time against progress instead: while the observed
value's expected-prefix grows — the same length-only evidence logCommitCadence
already emits — the wait continues, up to a 10s ceiling. A pipeline making no
progress expires at exactly the 3s the flat deadline used, so a wedge is
condemned no later than before. It is a reference type, and the observe/expire
coupling carries a structural guard, because as a struct that coupling would
rest on Swift boxing one captured var and could revert to the flat deadline
silently.

Text-entry readiness' hardware-keyboard fallback also stops returning a
possibly-unfocused element after 0.35s of "no software keyboard seen"; it now
returns only on confirmed focus of the target and re-arms otherwise. And the
keyboard-hidden precondition of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden skips rather than fails,
so an environment flip cannot read as a product regression.

The issue's remaining ask — pinning the simulator keyboard preference — is
deliberately not done: measured on a dedicated simulator, per-device
ConnectHardwareKeyboard makes no difference to a headless `simctl boot`, which
always shows the software keyboard. See the PR body for the A/B.

Closes #1874
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.44 MB 2.44 MB -216 B
JS gzip 817.6 kB 817.6 kB -57 B
npm tarball 941.6 kB 944.1 kB +2.6 kB
npm unpacked 3.27 MB 3.28 MB +7.8 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.60 MB 2.60 MB -216 B
Apple runner source/project 570.8 kB 578.8 kB +8.0 kB
macOS helper source 54.7 kB 54.7 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 45.3 kB 45.3 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.6 ms 27.8 ms -0.8 ms
CLI --help 79.7 ms 79.7 ms -0.0 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/sdk-batch-runner.js -77 B -27 B
dist/src/device-claim-conflict.js -39 B -9 B
dist/src/cli-help.js +9 B +4 B
dist/src/internal/daemon.js 0 B -1 B

Top changed packed files

Packed file Base Current Diff
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitBudget.swift 0 B 3.7 kB +3.7 kB
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift 20.0 kB 23.1 kB +3.1 kB
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift 19.6 kB 20.8 kB +1.2 kB
dist/src/screenshot-runtime.js 14.3 kB 14.1 kB -114 B
dist/src/sdk-batch-runner.js 78.5 kB 78.4 kB -77 B
dist/src/device-claim-conflict.js 90.1 kB 90.0 kB -39 B
dist/src/cli-help.js 89.4 kB 89.4 kB +9 B
dist/src/snapshot2.js 6.1 kB 6.1 kB +5 B

@thymikee

Copy link
Copy Markdown
Member Author

The +6.5 kB npm unpacked is the two new Swift files (the whole UITests bundle ships as source); JS shrinks by 216 B. Itemized in the PR body, with why a smaller design was rejected.

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed ba7e03f8854503666fd08931365a4ba2f5b4f7b8. The progress-aware behavior, focus confirmation, live iOS evidence, and all green checks are sound. One P2 maintainability issue remains, especially given the +6.5 kB unpacked growth: SynthesizedCommitBudgetTests exercise a detached class, while a new 61-line TypeScript parser proves that two escaping Swift closures happen to share it. The paragraph-long explanation that this tuple must retain a textual shape for another source parser is evidence the seam is wrong, and a reference type does not prevent the closures from being wired to different instances. Move the progress budget into the executable Swift polling path (awaitSynthesizedCommitOutcome and replacement counterpart) with the existing injected observation/pacing seams plus an injected clock. Make red-first Swift sequence tests exercise progress past 3s, no-progress expiry at 3s, and the 10s ceiling through the real wait; then delete the TypeScript wiring guard and detached scaffolding. Re-review after the head changes.

Review follow-up. The budget was a detached object tested in isolation, with a
TypeScript parser asserting that two escaping Swift closures happened to share
it — a guard that only existed because the seam was in the wrong place.

The budget is now a local `var` inside `awaitSynthesizedCommitOutcome` and its
replacement counterpart, advanced from the same observation the progress check
already reads, with the clock injected alongside the existing observation and
pacing seams. Recording progress and asking whether time is up are two
statements in one loop, so there is no coupling left to guard.

The detached tests and the TypeScript wiring guard are deleted. In their place,
four sequence tests drive the shipped waits through a hand-driven clock: a
prefix that keeps growing outlives the flat 3s deadline, a frozen prefix is
condemned at exactly 3s, an indefinitely throttled pipeline stops at the 10s
ceiling, and a value churning between two lengths buys no time. Verified red
first — the two progress tests fail against a no-op `record`, and the two
unchanged-behavior tests stay green.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed in e5bdccbfbb.

The budget is now a local var inside awaitSynthesizedCommitOutcome and its replacement counterpart, advanced from the same observation the progress check already reads, with the clock injected alongside the existing observation and pacing seams. Recording progress and asking whether time is up are two statements in one loop, so there is no cross-closure coupling left — the reference type went back to a value type, and the TypeScript wiring guard and the detached tests are deleted.

In their place, four sequence tests drive the shipped waits through a hand-driven clock: a growing prefix outlives the flat 3s deadline (settles at t=8s), a frozen prefix is condemned at exactly 3s, an indefinitely throttled pipeline stops at the 10s ceiling, and a value churning between two lengths buys no time. Red-first verified: the two progress tests fail against a no-op record; the two unchanged-behavior tests stay green, which is what they are for. The ten existing commit-wait tests moved to the same clock rather than an isExpired predicate.

Re-validated on iPhone 17 Pro / iOS 26.2: the full ios.yml PR list is 75 passed, 0 failures. Net effect on size is a reduction against the last report — one Swift file and one TypeScript file deleted.

🤖 Addressed by Claude Code

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 25, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-review at e5bdccbfbbd287c85ee42d4c45fe60bd6fc083ec: the P2 is resolved. Budget state is now local to each executable commit-wait loop; the detached Swift policy tests and TypeScript source parser are gone; clock-driven tests exercise the shipped append/replacement waits and prove progress past 3s, frozen-prefix expiry at 3s, the 10s ceiling, and churn buying no time. The exact-head iOS smoke and all required checks are green. No remaining code findings; ready for human review.

The synthesized commit wait started its clock before reading the field's
placeholder, and that read is an AX round-trip which takes seconds on exactly
the loaded host this budget exists for. Slow setup therefore spent the budget:
with a 3.5s placeholder read the first observation already exceeded the 3s
stall budget, so `type` reported TEXT_INPUT_COMMIT_NOT_OBSERVED after a single
poll — sooner than the flat deadline this replaced, in the one condition it was
written for.

The budget is now two durations, and only the poll loop starts it, from its own
first `now()`. Passing a pre-loop timestamp is no longer expressible. The poll
also takes one clock sample instead of two, so the instant an observation is
recorded at is the instant it is judged against.

testCommitWaitBudgetStartsAtTheLoopRatherThanBeforeIt pins it: 60s of setup
before the wait must still leave the full stall budget. Verified red against a
deadline started outside the loop.
@thymikee

Copy link
Copy Markdown
Member Author

Max-effort review of the branch found one real bug, now fixed in 9485e8052e.

The budget clock started before the wait, not at it. waitStartedAt was stamped before synthesizedCommitPollingIngredients, whose placeholderValue read is an AX round-trip — and on the loaded host this whole change exists for, AX reads were measured in seconds. With a 3.5s placeholder read the first observation already exceeded the 3s stall budget, so type returned TEXT_INPUT_COMMIT_NOT_OBSERVED after a single poll. The pre-PR code stamped its deadline inside the ingredients function, i.e. after that read, so it gave the field a full 3s. The change was making type/fill give up sooner than the code it replaced, in exactly the condition it targets.

The budget is now two durations, and only the poll loop starts it, from its own first now() — a pre-loop timestamp is no longer expressible at the call site. testCommitWaitBudgetStartsAtTheLoopRatherThanBeforeIt pins it (60s of setup must still leave the whole stall budget), verified red against a deadline started outside the loop.

Two smaller ones fixed alongside: the poll took two clock samples where one was meant, and record's doc still described a -1 unreadable sentinel that no caller passes any more.

Re-validated: policy set 28 passed, full ios.yml PR list 75 passed, 0 failures.

🤖 Addressed by Claude Code

…very fill

The `smoke:form-input` half of #1874 is not the commit deadline. This PR's own
iOS lane reproduced it (run 32889322172) and the trace settles it: `wait start
expectedLen=12`, then zero `[DEBUG-1874] poll` lines, then `wait
outcome=notObserved elapsedMs=3608`. The wait never polled — it returned from
the `textMatchesPlaceholder` guard, which refuses before polling because an
empty text field renders its placeholder AS its accessibility value, so a match
cannot prove a commit.

`field-name`'s placeholder was "Ada Lovelace" and every checkout-form suite
fills exactly "Ada Lovelace"; `field-email` had the same collision with
"ada@example.com". Twelve fills across eight files, so `fill` into those fields
is unverifiable by contract. It looked intermittent only because the
synthesized-replacement route is gated on `xCTestChannelPenalized` — it fires
when the host is loaded — which is also why re-running a failed job on the same
commit reproduced it identically.

The collision also made the read-back assertions vacuous: `assertJsonContains(
name, 'Ada Lovelace')` is satisfied by an empty field rendering the placeholder.

Fixed in the fixture rather than in the values, because frozen replay-compat
corpora carry the same fills and must not be edited.
fixture-fill-placeholder-collision.test.ts guards the class: it fails on any
repository fill whose value equals the target field's placeholder.
@thymikee

Copy link
Copy Markdown
Member Author

The max-effort review turned up something bigger than the three findings I posted, and it came from this PR's own failing iOS lane.

The smoke:form-input half of #1874 is not the commit deadline — it is the fixture. Run 32889322172 failed with the exact issue signature, and the trace settles it:

[DEBUG-1874] wait start expectedLen=12 route=replacement     19:35:00.652
   (no poll lines at all)
[DEBUG-1874] wait outcome=notObserved elapsedMs=3608         19:35:04.260

Zero poll lines. The wait never entered its loop — it returned from the textMatchesPlaceholder guard, which refuses before polling because an empty text field renders its placeholder as its accessibility value, so a match cannot prove a commit. expectedLen=12 is "Ada Lovelace", and field-name's placeholder was "Ada Lovelace". field-email had the same collision with ada@example.com. Twelve fills across eight files — every checkout-form flow in the repo asks fill for the one thing it documents it cannot verify. The 3.6s is just the AX resolution for the placeholder read ahead of the guard.

It looked intermittent because the synthesized-replacement route is gated on xCTestChannelPenalized, i.e. it fires when the host is loaded. That also explains the datapoint the issue found most puzzling — rerun_failed_jobs on the same commit reproducing identically. A deterministic guard on a load-gated route looks exactly like a sticky per-device flake.

It also made the read-back assertions vacuous: assertJsonContains(name, "Ada Lovelace") is satisfied by an empty field rendering the placeholder. So this strengthens the smoke rather than quieting it.

Fixed in a36b9ec91f by changing the two fixture placeholders — not the filled values, because frozen replay-compat corpora carry the same fills. fixture-fill-placeholder-collision.test.ts guards the class and was planted-red: before the change it lists all twelve.

Residual, flagged not fixed: fill still cannot verify a value equal to its field's placeholder, and the verdict is route-dependent — the ordinary xctest-element route reads the raw value back and passes where the penalized route refuses. Both are right on the evidence each has, but the same command can report differently under load. That belongs against #1924's three-state PlaceholderCommitEvidence model, in its own issue.

🤖 Addressed by Claude Code

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 25, 2026
@thymikee

Copy link
Copy Markdown
Member Author

[P2] Remove src/__tests__/fixture-fill-placeholder-collision.test.ts; keep the two placeholder changes and the existing Swift/live regression coverage. The 83-line test is a source-reconstruction guard, not a durable fixture invariant: it regex-parses JSX and only two literal fill syntaxes, duplicates Swift trim/equality semantics in TypeScript, and already misses the multiline real fill in test/integration/android-emulator-e2e/live-form-scenario.ts (plus expressions, spreads, .tsx, variables, typed clients, and unlisted roots). It can therefore stay green while its “Every fill” claim is false. The owning evidence already exists: Swift tests prove placeholder-equal AX values are unobservable, and live smoke:form-input failed on the prior head for exactly this fixture collision. If a global static contract is ever needed, expose fixture metadata and use the real .ad parser rather than regex-parsing source.

Current iOS smoke is still running; this is a code-quality blocker independent of that result.

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.

ios: text-entry lanes fail intermittently on unrelated branches (unpinned simulator keyboard state)

1 participant