Skip to content

refactor(daemon): Wave 6 — migrate clipboard, app-switcher, trigger-app-event, settings, alert, react-native and capabilities onto request-bound runtimes (R55–R63) - #2021

Merged
thymikee merged 16 commits into
mainfrom
claude/wave-6-agent-device-48rs1t
Aug 25, 2026
Merged

refactor(daemon): Wave 6 — migrate clipboard, app-switcher, trigger-app-event, settings, alert, react-native and capabilities onto request-bound runtimes (R55–R63)#2021
thymikee merged 16 commits into
mainfrom
claude/wave-6-agent-device-48rs1t

Conversation

@thymikee

Copy link
Copy Markdown
Member

Closes the named Wave 6 units from #1739. Seven commands leave platformExecution: legacy for ADR 0019 request-bound runtimes, and the legacy platform dispatcher goes with them.

What landed

Rule Command Shape
R55 clipboard Action-selected uses (readClipboard / writeClipboard) — a provider can genuinely have the getter without the setter
R56 app-switcher Single appSwitcher use on the generic route
R57 trigger-app-event Single triggerAppEvent use; URL templating stays daemon-side, only a resolved URL crosses
R58 settings Single setSetting use — the last DISPATCH_HANDLERS arm, so this retires dispatchCommand, dispatchWithInteractor, dispatchKnownCommand and the whole legacy dispatcher
R59 alert Four action-selected uses (read/wait/accept/dismiss); the Apple legs move to src/platforms/apple/alert.ts, Android's supply their own node capture
R61 react-native Admits the owner's own tapPoint ahead of the observing capture
R63 capabilities Projects every command's declared uses against one side-effect-free facts inspection, replacing the hand-maintained list

Residue reclassified to platformExecution: none (legacy by delegation only, delegate already migrated): batch, debug, events, capabilities.

Legacy descriptors: 32 → 9. Modes are now { none: 21, inventory: 1, legacy: 9, device-runtime: 45 }.

Two pre-existing product defects repaired

R48 moved press off the legacy dispatcher but left two callers on dispatchCommand(device, 'press', …), which had been throwing INVALID_ARGS: Unknown command: press on main ever since:

  • React Native overlay dismissal always failed.
  • --retry-on-no-change threw out of the snapshot capture it was decorating.

Both now ride the bound tapPoint. The old tests hid this by mocking dispatchCommand.

Deliberate behavior changes

Recorded here because none of them is a migration accident:

  • react-native dismiss-overlay widens to Linux, web and HarmonyOS. The retired bucket was {apple, android, linux: {}}; all three admit tapPoint, and a surface with no overlay answers detected: false — the truthful result. A family cannot be a support authority for a migrated command (§8).
  • Provider-owned physical Apple clipboard is now admitted. supportsHostOrSimulatorSurface was a local-Apple predicate applied to a device driven over Appium, which does expose the clipboard extension. The refusal moves into the interactor, per session.
  • Provider-owned alert is now refused up front. supportsAlertSurface admitted it on appleOs === 'ios' alone, and the handler then drove the local XCTest runner against a device in someone else's cloud. Nothing the provider owns can serve an alert leg.
  • Refusal envelopes. Migrated commands refuse at admission with the owner's fact hint instead of a bare capability message; provider refusals lose the interactor's details, and supportedOn disappears (it is derived from capability, which a migrated descriptor no longer carries). This matches every prior wave — back lost supportedOn in R42.
  • platform_command_prepare / platform_command diagnostics are gone with the dispatcher that emitted them. JSON responses and cost.runnerRoundTrips are unaffected.

Analyzed, deliberately not landed

  • audio (R60) is durable-tier, not request-scoped: it owns a session-scoped child process and a status file that survive across requests. It needs the durable-resource treatment logs/record got, not a device-runtime use.
  • doctor (R62) probes the host toolchain. There is no platformExecution kind for host-scoped work, so classifying it needs an ADR-level discriminator decision first.
  • react-devtools is blocked by the mode-dominance gate: it injects the device-runtime runtime command.
  • daemon, web, replay, test keep genuine platform edges in their own CLI modules.

Review

Three independent adversarial reviews (behavior parity, correctness, ADR 0019 conformance) ran before this opened; 95d609c is what they turned up. The substantive ones:

  • The retry seam added by R58 was unreachable — no caller forwarded the request's bindings into captureSnapshot, so every retry resolved to a skip. The snapshot route now threads them through.
  • A rejecting retry tap escaped the capture and turned a plain snapshot into an error; it is caught and reported as a skip, and the attempt is spent before the device work as the retired route did.
  • react-native reached its required tapPoint through ?. and reported dismissed: true when it was absent.
  • capabilities declared none while binding three times (§6 forbids exactly that). Every owner composes a binding's facts with the same function inspectFacts calls, so the probes read back values the single inspection already carries — at the cost of a device claim on a read-only query. Removed, along with the last three empty-required uses §9 retires.
  • The retry tap re-admitted once per retry round (§9 is one bind per handler); it memoizes per device.
  • Dead vocabulary the cutovers created: requireCommandSupported (last caller left with R56), CommandDispatchFacet / descriptor.dispatch / explain's dispatch= field, and three vacuous expect(dispatchCalls).toEqual([]) assertions fed by a mock of an export that no longer exists.

Verification

pnpm check:affected --run is green apart from two failures that reproduce identically on origin/main (app-log-session-resource and durable-capture-resource-adoption chmod a file unwritable, which root ignores in this container). pnpm check:layering reports OK with all 45 migrated commands holding exactly one platform-execution path; pnpm check:fallow --base origin/main reports no issues across 195 changed files.

The R9 type-cycle ceiling drops 21 → 20 (session-install-capability-projection.ts deleted), and R58's callback seam had already taken it 25 → 21.

🤖 Generated with Claude Code

https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.43 MB 2.44 MB +11.7 kB
JS gzip 814.2 kB 817.6 kB +3.3 kB
npm tarball 938.5 kB 941.5 kB +3.0 kB
npm unpacked 3.25 MB 3.27 MB +13.2 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.58 MB 2.60 MB +12.8 kB
Apple runner source/project 570.6 kB 570.8 kB +242 B
macOS helper source 54.5 kB 54.7 kB +162 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 27.4 ms 28.7 ms +1.3 ms
CLI --help 82.3 ms 81.7 ms -0.7 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session-snapshot.js -12.9 kB -4.0 kB
dist/src/interaction.js -5.2 kB -1.3 kB
dist/src/session2.js +2.5 kB +609 B
dist/src/src2.js -1.8 kB -601 B
dist/src/device-claim-conflict.js +1.2 kB +456 B

Top changed packed files

Packed file Base Current Diff
dist/src/snapshot-state.js 0 B 20.3 kB +20.3 kB
dist/src/session-snapshot.js 38.8 kB 26.0 kB -12.9 kB
dist/src/dispatch.js 10.0 kB 0 B -10.0 kB
dist/src/alert-runtime.js 0 B 8.0 kB +8.0 kB
dist/src/keyboard-runtime.js 7.1 kB 0 B -7.1 kB
dist/src/interactors.js 6.5 kB 557 B -6.0 kB
dist/src/register-builtins.js 0 B 5.3 kB +5.3 kB
dist/src/interaction.js 37.0 kB 31.7 kB -5.2 kB
dist/src/react-native-overlay2.js 0 B 3.7 kB +3.7 kB
dist/src/react-native-overlay.js 4.4 kB 857 B -3.6 kB

@thymikee

Copy link
Copy Markdown
Member Author

Blocking review at 95d609cb7cad2d3ce7ecfd7569f3b314267a53e1:

  1. P1 — Apple alert retry behavior still keys on prose and hides real failures. src/platforms/apple/alert.ts:73-83 catches every read failure during alert wait, so transport/runner/helper failures become alert wait timed out. Lines 109-115 and 130-132 decide action retry and hint eligibility from "alert not found" / "no alert" substrings. That violates the repository invariant that behavior keys on typed reasons/details. Add a typed alert-not-found reason at the Apple runner/helper boundary, preserve it through normalization, and retry/attach the fallback hint only for that reason. Required tests: non-alert wait failures propagate immediately; wait/action retry the typed absence only; the hint attaches only to typed absence.

  2. P1 — The PR bypasses Wave 6's command-unit and size-review contract. refactor(daemon): platform-free daemon — broader migration waves (successor to #1696) #1739 says these seven descriptors each get a normal unit and requires the descriptor/cell/parity/facet/deletion/evidence/budget record before the wave; it still says Wave 6 is not started. This PR combines all seven in 194 files, about +1,570 net production lines, with npm unpacked +12.9 kB. The body reports aggregates but does not itemize growth contributors or explain why a materially smaller design was rejected. Split this into the recorded command-atomic units, or first obtain an explicit tracker escalation containing all seven required records and per-unit budgets. Each resulting device-facing unit also needs exact-head practical evidence for its changed production route before readiness.

CI is otherwise green except iOS smoke still in progress at review time. No ready-for-human label until both blockers are resolved and the head is re-reviewed.

thymikee pushed a commit that referenced this pull request Aug 24, 2026
Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed 289492dd06c8a4e3692d01429559b4c08d1ac66c.

The typed alert fix resolves the first prior P1: XCTest and the macOS helper now emit typed absence evidence, host retry/hint policy branches only on those fields, and the planted regressions prove message-like but untyped failures propagate immediately.

The Wave 6 scope/size P1 remains. This commit does not split the seven-command PR or update #1739 with the required pre-wave per-unit records, budgets, itemized growth, and rejected-smaller-design rationale. Please split the units or obtain explicit tracker escalation and record that evidence before this can be ready.

Native evidence is also currently red: Android smoke fails keyboard status after ADB exits 255 (with earlier device-offline symptoms), and iOS smoke stalls before any readable fixture capture. Neither failure is proven caused by this alert-only delta, but both remain unclassified; reproduce against origin/main under the same load or provide a clean rerun before readiness.

Copy link
Copy Markdown
Member Author

Both prior points addressed as far as I can take them.

Wave 6 scope P1. The pre-wave record is now on #1739 (comment) with the per-unit descriptor/owner/cell/parity/facet/deletion/tier table, measured per-unit budgets, itemized growth, and the rejected-smaller-design rationale. The escalation is requested there, not assumed — if you would rather have seven PRs, the commits are already command-atomic and I will split.

Two things in that record are worth surfacing here because they are not favourable:

  • The wave is not move-dominated. Root src/ grew by 221 production lines; only R58 shrank it. The largest single contributor is src/platforms/apple/alert.ts (+~140) — R59 moved the Apple alert legs out of the daemon but into root src/platforms/ instead of packages/platform-apple. That is a genuine miss, and moving it would turn the biggest root-growth item into package growth. I left it out of this PR only to avoid widening an already-large diff; happy to land it here on your word.
  • I cannot produce live device evidence. No simulator, emulator, or macOS host in this environment. The record lists the routes needing exact-head capture. I would rather flag that than present suite coverage as per-unit evidence.

Native CI. Classified, with one re-run spent:

  • Android — failed once (keyboard status after ADB exit 255, device-offline), passed on re-run. My diff contains no Android or ADB code, so this was a one-off in that window.

  • iOS — failed twice, but at different steps with different errors, which is not a reproduction:

    • first: wait text timed out at 30 s, "no readable snapshot capture completed";
    • re-run: reached 150 s, cleared open/wait/navigation, then failed at fill full name with TEXT_INPUT_COMMIT_NOT_OBSERVED.

    That second one is a documented, explicitly load-dependent condition — website/docs/docs/commands.md:430 describes it as the coordinate-driven fill route "taken when the accessibility channel is under load". Neither failure is on a path this delta touches: the diff is alert-only, plus one code: field on the runner's alert error response.

    The one way this delta plausibly perturbs iOS smoke is the runner build fingerprint: runner-cache-metadata.ts:243-251 hashes .swift source contents, so any Swift edit forces a cold xcodebuild build-for-testing. That fits the 30 s → 150 s jump between attempts. It is inherent to typing the runner's absence response at all — which is what the first P1 asked for — and the harness budgets 7 minutes for prepare precisely for this.

I have used my one re-run and will not keep re-running. If you want the base-branch comparison under the same load, that needs a main run I cannot trigger from here.


Generated by Claude Code

@thymikee
thymikee force-pushed the claude/wave-6-agent-device-48rs1t branch from 289492d to be4d038 Compare August 25, 2026 06:26
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
@thymikee

Copy link
Copy Markdown
Member Author

Single-PR shape accepted. I reviewed the eight commit units holistically at be4d0388d2250c4c33ecdfa26ec67c473c77ab07 and exercised the migrated paths locally. This is not ready yet: one correctness blocker and two concrete simplifications remain.

P1 — facts over-admit operations that execution can still refuse. On a Pixel 9 Pro XL / Android 36 emulator, this exact head reports clipboard in capabilities, then clipboard read returns UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this device. packages/platform-android/src/runtime.ts admits both clipboard legs through androidTouchFact, and its test asserts availability for every real Android kind, while src/platforms/android/device-input-state.ts discovers unsupported shell behavior only during execution. WebDriver preserves the same split: platform-runtime.ts admits clipboard/app-switcher from interactor reachability, while webdriver-interactor.ts::requireSupport can reject the provider capability at call time. The paragraph-long comments explaining this are evidence that admission is at the wrong owner seam. Make Android shell support and CloudWebDriverProviderCapabilities inputs to fact generation; an operation that binds must already be admitted. Add parity tests for an unsupported Android shell and WebDriver capability overrides.

P2 — collapse the binder catalog. interactor-operation-catalog.ts represents the same operation set as a tuple plus complete local and provider binder maps, then erases results into Partial<PlatformRuntimeOperations>. The per-operation local/provider wrappers largely only choose a resolver source and diagnostic label. Replace these with one typed operation-definition catalog/generic resolver adapter; keep each facet's typed executor and delete the mirrored wrappers/maps.

P2 — split and parameterize the enforcement table. runtime-command-cutover-table.ts is now 1,325 lines and this PR adds 222 more. Move the Wave 6 rows to a sibling module and replace the six identical *RetiredDispatchProjectionProof wrappers with a parameterized retiredDispatchProjectionProof(command) factory.

The retry binding pair is a smell, but not a separate blocker here: current public snapshot composition forwards both seams, internal/legacy captures deliberately skip with a diagnostic, and a retry failure must not replace a valid snapshot. Once those legacy routes migrate, make retry-capable capture require one constructed retry seam and delete the optional pair.

Local exact-head evidence: build, Android helper build, and XCTest runner build passed. iOS Simulator passed clipboard Unicode round-trip/restore, app-event with visible exact name+JSON payload, alert get/dismiss/accept with visible result canaries, app-switcher, appearance toggle/restore, and RN no-overlay. Android Emulator passed app-event with visible exact canaries, alert dismiss/accept, app-switcher, appearance toggle/restore, RN no-overlay, and persistent helper snapshot; clipboard produced the blocker above. Current GitHub checks are all green. No label applied because of the confirmed findings.

thymikee pushed a commit that referenced this pull request Aug 25, 2026
…tion checks

Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator:
`capabilities` advertised `clipboard`, then `clipboard read` failed with
`UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this
device.` Admission and execution were consulting different authorities, which
ADR 0019 §2 forbids — a bound operation must already be admitted.

Android. `cmd clipboard` has no shell implementation on every build, and the
retired bucket admitted both halves on every real Android kind, leaving the leaf
to discover the refusal after the fact. Support is now a fact: the owner probes
once per device (cached for its lifetime — a build's shell command set cannot
change while the device is up) and states `owner-capability-missing` when adb
names the condition. The probe is definitive in one direction only: adb saying
so means unsupported, a probe that cannot run means unknown, and reporting
unknown as unsupported would hide a working clipboard behind a transport
hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support`
so admission and the leaf's own defense-in-depth check cannot drift apart.

Cost, stated plainly: the first facts inspection per device now spends one adb
round trip, including for requests that never touch the clipboard.

WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`,
while fact generation admitted from interactor reachability alone — so a
provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }`
was admitted and then thrown out of. The declared capability map is now an input
to fact generation, and the refusal carries the map author's own note. Applied to
every operation with an unambiguous capability key, not just the two named in
review: the mechanism is identical and a half-applied fix would leave the same
defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is
unchanged by default — every one of those is `supported` or `partial` in the base
map — so only an explicit override bites. `focus`, the gesture tiers and
`trigger-app-event` keep reachability: no capability key maps to them 1:1.

Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in
the cutover table into one parameterized factory (second review point).

Parity tests: an Android build reporting either unsupported-shell phrasing, the
probe cache, an adb failure staying admitted, and a WebDriver override refused at
admission for each keyed operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325
lines, past the point where one read covers it.

Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are
spread back in, leaving the table at 1,095 lines. The split is by wave because
that is how these rows are retired: a wave's rows are deleted together once the
ADR declares its commands' migrations closed, and deleting a whole file is a
cleaner end than excising a run of literals from the middle of a larger one.

`retiredDispatchProjectionProof` moves to the shared extensions module, since
both tables now use it — the main table for `snapshot`/`diff`, the sibling for
its own eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

Copy link
Copy Markdown
Member Author

Addressed at eb76acc. All 19 checks green, four smoke lanes included.

P1 — facts over-admitting. Fixed on both owners.

Android: cmd clipboard shell support is now a fact. The owner probes once per device and caches for its lifetime (a build's shell command set cannot change while the device is up), and states owner-capability-missing when adb names the condition. The predicate moved to @agent-device/contracts/android-clipboard-support so admission and the leaf's own defense-in-depth check read the same code and cannot drift.

The probe is deliberately definitive in one direction only: adb saying so means unsupported, a probe that cannot run means unknown. Reporting unknown as unsupported would hide a working clipboard behind a transport hiccup, so that case stays admitted and lets the operation report the real error. Cost, stated plainly: the first facts inspection per device now spends one adb round trip, including for requests that never touch the clipboard.

WebDriver: CloudWebDriverProviderCapabilities is an input to fact generation, using the same capabilitySupported predicate requireSupport uses, and the refusal carries the capability map author's own note.

One judgement call worth flagging: I applied this to every operation with an unambiguous capability key, not just the two you named — back, home, orientation, tap, longPress, fill, type, scroll had the identical defect and the mechanism is the same, so a half-applied fix would have left it in place for the neighbours. Behavior is unchanged by default (all of those are supported or partial in the base map); only an explicit override bites. focus, the gesture tiers and trigger-app-event keep reachability because no capability key maps to them 1:1.

Parity tests as requested: an Android build reporting either unsupported-shell phrasing, the probe cache, an adb failure staying admitted, and a WebDriver override refused at admission for each keyed operation.

P2 — enforcement table. Done. Wave 6's eight rows moved to runtime-command-cutover-table-wave6.ts, leaving the table at 1,095 lines from 1,325. Split by wave because that is how these rows retire — a wave's rows are deleted together, and deleting a file beats excising a run of literals from the middle of a larger one. The eight identical *RetiredDispatchProjectionProof wrappers are now one retiredDispatchProjectionProof(command) factory in the shared extensions module.

P2 — binder catalog. Not done. I have confirmed it is tractable — every facet binder already shares a (signal, resolveInteractor) signature, and keyboard/alert curry cleanly — so one { operation, label, bind } catalog plus a generic adapter would delete both maps, the parallel tuple, and 32 mirrored wrappers. What stopped me is that it also rewrites ~10 facet test files onto the exported executors, in packages/contracts, which every platform package depends on. I did not want to land a broad refactor of shared vocabulary unreviewed in the same push as a correctness fix you are waiting on. Say the word and it is the next thing I do.

Still true from before: I cannot produce live device evidence here, so thank you for exercising the migrated paths locally — the clipboard blocker is exactly the kind of thing CI could not have told me.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed eb76acc holistically under the thermo-nuclear quality bar. Current CI is fully green and the exact-head Android success path is now proven locally, but this is still not ready.

P1 — the probe failure path recreates the admission lie and caches it. In packages/platform-android/src/runtime.ts:238-246,468-485, any adb exception becomes true, then that result is cached by device ID for the runtime owner’s lifetime. A transient offline/timeout therefore makes capabilities advertise clipboard even on a build with no clipboard shell, and execution can fail with the same unsupported result this fix is meant to prevent. The new test explicitly locks that behavior in. Do not equate unknown with supported: keep a typed supported/unsupported/probe-failed result, cache only definitive support, and propagate or conservatively refuse a probe failure so callers never receive fabricated availability.

The same correction should repair the ownership boundary. @agent-device/contracts/android-clipboard-support now contains an adb stdout/stderr substring parser, even though ADR 0019 assigns platform output parsing to the Android owner and repository rules forbid expanding message-sniff debt. Normalize raw adb output once at the Android boundary into typed evidence; contracts should carry the typed fact, not parse tool prose. The long comment explaining why the fallback is acceptable is evidence the invariant is not represented in code.

P1 maintainability — finish the binder-catalog simplification already identified. interactor-operation-catalog.ts still declares the same operation set as a tuple plus complete local and provider maps, while the facets retain mirrored local/provider wrappers and local-interactor-operation-set.ts adds another aggregation layer. The author confirmed a single typed operation-definition catalog with generic local/provider adapters is tractable. Please land that code-judo now: define each operation once, preserve the typed facet executors, and delete the tuple, mirrored maps, and wrappers rather than carrying known ceremony out of a +6,211/−2,708 PR.

Exact-head evidence gathered locally on Pixel 9 Pro XL/API 36 (emulator-5556, isolated state dir): build and Android helper build passed; open com.android.settings passed after one boot-time adb timeout; capabilities omitted clipboard; clipboard read refused up front with UNSUPPORTED_OPERATION and the owner-capability hint; snapshot -i confirmed android-helper v0.20.10 with healthy quality. Focused Android/WebDriver runtime tests also passed (42 tests). The session, daemon, and coordinator-created emulator were cleaned up.

No ready-for-human label until these two owning-interface/structural blockers are fixed.

thymikee pushed a commit that referenced this pull request Aug 25, 2026
Review blocker on #2021. The probe I added had a `catch { return true }`, then
cached that result by device id for the runtime owner's lifetime. A transient
adb offline or timeout therefore made `capabilities` advertise the clipboard on
a build with no clipboard shell — recreating the exact lie the fix was for, and
pinning it for the rest of the session. A test locked the behavior in.

Support is now a typed verdict with three states, because "we could not ask" is
not "it works": `supported | unsupported | probe-failed`. Only a definitive
answer is cached; `probe-failed` refuses conservatively with a hint saying
support could not be determined, and is deliberately not remembered, so the next
inspection asks again.

The same change repairs the ownership boundary. Turning raw adb stdout/stderr
into a verdict is Android tool knowledge, so it belongs to the Android owner, not
to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now
carries the typed union alone. The parser returns to `src/platforms/android/adb.ts`
and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport`
that hands owners the verdict. That also settles which Android home owns it:
R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`,
so a parser shared between the package and the root leaf cannot live in the
package either.

Tests now cover the failure path the previous ones locked the wrong way: a failed
probe refuses instead of admitting, its refusal says it could not determine
support rather than claiming the build lacks it, and it is not cached — a second
inspection re-probes and admits once the device answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed b50a9c2. Not ready yet.

P1 — the root adapter still fabricates clipboard support from nonzero adb results. In src/platform-runtime-android-tool-host.ts, the probe calls runAndroidAdb(..., { allowFailure: true }); nonzero exits therefore return normally. The adapter maps every result not matching the two missing-shell phrases to supported, so device-offline, permission, timeout-shaped returned failures, or generic adb errors are admitted and then cached indefinitely by packages/platform-android/src/runtime.ts. Return unsupported only for recognized missing-shell evidence, supported only for exitCode === 0, and probe-failed for every other result. Add an adapter-level planted-red regression for a generic nonzero adb result; the current package tests mock the typed verdict downstream and cannot catch this bug.

The contracts ownership issue is resolved: contracts now carry only the typed verdict and Android owns raw adb parsing.

P1 maintainability — the binder-catalog simplification is still unchanged. No relevant catalog/facet files changed from the prior head. The operation tuple, complete local/provider maps, mirrored facet wrappers, and local aggregation layer still duplicate the same operation set. Land the previously agreed single typed operation-definition catalog with generic local/provider adapters and delete the parallel declarations/wrappers.

Current checks are mostly green with smoke/coverage/repo guards still running. No ready-for-human label while these findings remain.

thymikee pushed a commit that referenced this pull request Aug 25, 2026
Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same
operation set three times — a name tuple, a complete local binder map, and a
complete provider binder map — and each facet carried a mirrored
`bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was
which interactor source to use and which label a refusal names.

There is now one row per operation, carrying its facts key, its provider refusal
label, and the facet's own executor. The local/provider split lives in the two
adapters, which differ by exactly the thing that differs: the interactor source.
Adding an operation is adding one row.

Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten
facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases
that existed only to be re-exported. Kept: every facet's typed executor, now
exported as its binding surface.

Net −563 production lines in `packages/contracts`.

Two consumers moved onto the catalog's public entry point rather than keeping a
private path to a single operation: the app-event delivery test and the provider
scenario fixture, whose two hand-bound keyboard legs are now whichever legs its
facts admit. Each facet's tests spell out the composition the retired wrappers
performed, so every assertion still exercises one executor reached through one
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

Copy link
Copy Markdown
Member Author

Both blockers addressed at b223f47.

P1 — the probe failure path. You were right, and my reasoning was the wrong shape: I argued "unknown shouldn't hide a working clipboard" without following it through to the consequence, which is that one transient adb blip makes capabilities advertise a clipboard the build genuinely lacks — and my cache then pinned that answer for the owner's whole life. Worse, I wrote a test that locked it in.

Support is now a typed verdict with three states — supported | unsupported | probe-failed — because "we could not ask" is not "it works". Only definitive answers are cached. probe-failed refuses, its hint says support could not be determined rather than claiming the build lacks it, and it is deliberately not remembered, so the next inspection asks again.

The ownership correction came with it. @agent-device/contracts/android-clipboard-support now carries the typed union alone; the adb parser is back at src/platforms/android/adb.ts and runs in exactly one place, behind a new AndroidToolHost.probeClipboardShellSupport that hands owners the verdict. That also settled which Android home should own it: R13 lets only src/platform-runtime.ts import @agent-device/platform-android, so a parser shared between the package and the root leaf could not have lived in the package either.

One design consequence worth flagging: the host method is optional, and a host that does not implement it gets a refusal, not an admission. I made it optional after a required version broke unrelated boot/shutdown paths whose fixtures reach the real Android runtime — but absence of a probe is absence of evidence, so it refuses. There is a test for that case specifically.

P1 — binder catalog. Landed. One row per operation carrying its facts key, its provider refusal label, and the facet's own executor; the local/provider split lives in the two adapters, differing by exactly the thing that differs — the interactor source.

Deleted: the parallel name tuple, both binder maps, 32 mirrored wrappers across ten facet modules, and the per-facet Local…/Provider…InteractorResolver aliases that existed only to be re-exported. Kept: every facet's typed executor, now its public binding surface. Net −563 production lines in packages/contracts.

Two consumers moved onto the catalog's public entry point rather than keeping a private path to one operation — the app-event delivery test, and the provider scenario fixture whose two hand-bound keyboard legs are now whichever legs its facts admit. Each facet's tests spell out the composition the retired wrappers performed, so every assertion still exercises one executor through one source.

Ten eager-closure-budgets pins dropped from 4 modules to 1 (keyboard to 3) as a side effect — the facets no longer pull in the interactor-source helpers at all.

Verification. pnpm check:affected --run is green: format, lint, typecheck, layering, di-seams, fallow (no issues across 217 changed files), mcp-metadata, build, package, integration-node, macos-coverage. Unit suite 1119/1121 files, the two failures being the pre-existing chmod tests that root ignores in this container.

I still cannot produce live device evidence here, so the Android clipboard paths want your emulator again — particularly the probe-failed refusal, which is the one I got wrong the first time.


Generated by Claude Code

thymikee pushed a commit that referenced this pull request Aug 25, 2026
Third review P1 on #2021. The typed verdict landed one layer too high. The
adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a
non-zero exit comes back as an ordinary result rather than a throw — and the
only thing standing between that result and `supported` was the missing-shell
prose check. A device that had gone offline, was unauthorized, timed out, or
failed for any other reason produced none of that prose, so it fell through to
`supported` and was then cached by device id for the runtime owner's lifetime.
The `catch` I added guarded the one path adb almost never takes.

Each adb outcome now proves only what it can:

- `exitCode === 0` is the sole evidence of support, because it is the only
  result that shows the command ran.
- The recognized missing-shell prose is the sole evidence of absence, and is
  read before the exit code — adb reports that condition non-zero, so checking
  the code first would turn every honest `unsupported` into a refusal.
- Everything else — non-zero without that prose, and the transport throw — is
  `probe-failed`, which admission refuses and the cache does not remember.

The package tests mocked the typed verdict, so they sat downstream of the bug
and could not see it. The regression is therefore at the adapter, over the raw
adb result: four planted reds (offline, unauthorized, device-not-found, generic
failure) that all returned `supported` before this change, plus the two
definitive verdicts and the ordering case that keeps `unsupported` reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

Copy link
Copy Markdown
Member Author

Both P1s are addressed. One of them had already landed before you reviewed — you were reading a stale head on that one.

P1 — the adapter fabricated support from non-zero adb results. Confirmed, and your reading of the mechanism was exactly right. allowFailure: true means a non-zero exit returns normally, so the catch I was relying on guarded the one path adb almost never takes; everything without the missing-shell prose fell through to supported and was then cached by device id for the owner's lifetime. Fixed in 7e1f391:

if (isClipboardShellUnsupported(result.stdout, result.stderr)) return 'unsupported';
return result.exitCode === 0 ? 'supported' : 'probe-failed';

The prose check runs before the exit code on purpose: adb reports the missing shell command non-zero, so reading the code first would turn every honest unsupported into a refusal.

The regression is at the adapter over the raw adb result, as you asked — src/__tests__/platform-runtime-android-clipboard-probe.test.ts. Four planted reds (device offline, unauthorized, device-not-found, generic error: closed), plus the two definitive verdicts and the ordering case. I verified the red rather than assuming it: reverting the adapter body to the previous logic gives 4 failed / 5 passed; restoring it gives 9/9.

P1 maintainability — the binder catalog. Already landed: b223f47 ("refactor(contracts): declare each interactor operation once") was committed at 12:33 UTC, ~14 minutes after your review of b50a9c2, so it isn't in the head you read. interactor-operation-catalog.ts is now a single 162-line table of {operation, label, bind} rows, with two adapters that differ only by source (localInteractorSource(params) vs providerInteractorSource({ ...params, operation: definition.label })). LOCAL_BINDERS, PROVIDER_BINDERS and the mirrored per-operation wrappers no longer exist anywhere in the tree.

iOS smoke on b50a9c2. It failed at agent-device fill with a runner-side TEXT_INPUT_COMMIT_NOT_OBSERVED. I don't believe it belongs to this branch: the two preceding heads (be4d038, eb76acc) passed that same job, and b50a9c2's entire delta is the Android clipboard probe. fill is not one of the catalog's operations, and the branch's only runner Swift edit is the alert ErrorPayload(code:)RunnerTests+TextEntry.swift is untouched. The push above gives it a fresh run; if it fails at the same step again I'll treat it as this PR's and dig in rather than re-running.

Local gate on 7e1f391: typecheck clean across all package tsconfigs, layering 184/184, fallow clean over 218 changed files, 1120/1122 test files passing. The two failures are the pre-existing app-log-session-resource / durable-capture-resource-adoption pair that chmod a file unwritable — root ignores that in a container, and they fail on base too.

The live-device gap I flagged earlier still stands: I can't run an emulator here, so the probe-failed refusal remains the path that most wants your hardware — it's the one I've now gotten wrong twice, in two different places.


Generated by Claude Code

@thymikee

thymikee commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed 7e1f3910. The binder-catalog blocker is resolved materially: one typed operation-definition catalog now feeds generic local/provider adapters, the parallel tuple/maps and mirrored wrappers are deleted, and contracts shrink substantially. The prior generic-nonzero probe bug is also mostly fixed.

P1 — successful clipboard contents can be cached as unsupported-shell evidence. src/platform-runtime-android-tool-host.ts calls isClipboardShellUnsupported(stdout, stderr) before checking exitCode. On exit 0, stdout is the user's clipboard text; clipboard content containing unknown command or no shell command implementation is therefore classified as unsupported and cached for the runtime owner's lifetime. Interpret missing-shell prose only on nonzero exit. Add adapter-level exit-0 regressions containing both phrases that must remain supported; keep nonzero recognized prose as unsupported and other nonzero/throws as probe-failed.

GitHub currently reports CONFLICTING/DIRTY, although a local merge-tree against the reported base is clean, so its conflict state may be stale. The exact head also has only CodeQL checks; the normal validation and smoke matrix is absent. No ready-for-human label until the correctness fix lands, GitHub recomputes mergeability, and full exact-head validation runs.

@thymikee
thymikee force-pushed the claude/wave-6-agent-device-48rs1t branch from 7e1f391 to 6cfa4e9 Compare August 25, 2026 13:09
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
thymikee pushed a commit that referenced this pull request Aug 25, 2026
…tion checks

Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator:
`capabilities` advertised `clipboard`, then `clipboard read` failed with
`UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this
device.` Admission and execution were consulting different authorities, which
ADR 0019 §2 forbids — a bound operation must already be admitted.

Android. `cmd clipboard` has no shell implementation on every build, and the
retired bucket admitted both halves on every real Android kind, leaving the leaf
to discover the refusal after the fact. Support is now a fact: the owner probes
once per device (cached for its lifetime — a build's shell command set cannot
change while the device is up) and states `owner-capability-missing` when adb
names the condition. The probe is definitive in one direction only: adb saying
so means unsupported, a probe that cannot run means unknown, and reporting
unknown as unsupported would hide a working clipboard behind a transport
hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support`
so admission and the leaf's own defense-in-depth check cannot drift apart.

Cost, stated plainly: the first facts inspection per device now spends one adb
round trip, including for requests that never touch the clipboard.

WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`,
while fact generation admitted from interactor reachability alone — so a
provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }`
was admitted and then thrown out of. The declared capability map is now an input
to fact generation, and the refusal carries the map author's own note. Applied to
every operation with an unambiguous capability key, not just the two named in
review: the mechanism is identical and a half-applied fix would leave the same
defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is
unchanged by default — every one of those is `supported` or `partial` in the base
map — so only an explicit override bites. `focus`, the gesture tiers and
`trigger-app-event` keep reachability: no capability key maps to them 1:1.

Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in
the cutover table into one parameterized factory (second review point).

Parity tests: an Android build reporting either unsupported-shell phrasing, the
probe cache, an adb failure staying admitted, and a WebDriver override refused at
admission for each keyed operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325
lines, past the point where one read covers it.

Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are
spread back in, leaving the table at 1,095 lines. The split is by wave because
that is how these rows are retired: a wave's rows are deleted together once the
ADR declares its commands' migrations closed, and deleting a whole file is a
cleaner end than excising a run of literals from the middle of a larger one.

`retiredDispatchProjectionProof` moves to the shared extensions module, since
both tables now use it — the main table for `snapshot`/`diff`, the sibling for
its own eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Review blocker on #2021. The probe I added had a `catch { return true }`, then
cached that result by device id for the runtime owner's lifetime. A transient
adb offline or timeout therefore made `capabilities` advertise the clipboard on
a build with no clipboard shell — recreating the exact lie the fix was for, and
pinning it for the rest of the session. A test locked the behavior in.

Support is now a typed verdict with three states, because "we could not ask" is
not "it works": `supported | unsupported | probe-failed`. Only a definitive
answer is cached; `probe-failed` refuses conservatively with a hint saying
support could not be determined, and is deliberately not remembered, so the next
inspection asks again.

The same change repairs the ownership boundary. Turning raw adb stdout/stderr
into a verdict is Android tool knowledge, so it belongs to the Android owner, not
to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now
carries the typed union alone. The parser returns to `src/platforms/android/adb.ts`
and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport`
that hands owners the verdict. That also settles which Android home owns it:
R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`,
so a parser shared between the package and the root leaf cannot live in the
package either.

Tests now cover the failure path the previous ones locked the wrong way: a failed
probe refuses instead of admitting, its refusal says it could not determine
support rather than claiming the build lacks it, and it is not cached — a second
inspection re-probes and admits once the device answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same
operation set three times — a name tuple, a complete local binder map, and a
complete provider binder map — and each facet carried a mirrored
`bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was
which interactor source to use and which label a refusal names.

There is now one row per operation, carrying its facts key, its provider refusal
label, and the facet's own executor. The local/provider split lives in the two
adapters, which differ by exactly the thing that differs: the interactor source.
Adding an operation is adding one row.

Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten
facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases
that existed only to be re-exported. Kept: every facet's typed executor, now
exported as its binding surface.

Net −563 production lines in `packages/contracts`.

Two consumers moved onto the catalog's public entry point rather than keeping a
private path to a single operation: the app-event delivery test and the provider
scenario fixture, whose two hand-bound keyboard legs are now whichever legs its
facts admit. Each facet's tests spell out the composition the retired wrappers
performed, so every assertion still exercises one executor reached through one
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
thymikee pushed a commit that referenced this pull request Aug 25, 2026
Third review P1 on #2021. The typed verdict landed one layer too high. The
adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a
non-zero exit comes back as an ordinary result rather than a throw — and the
only thing standing between that result and `supported` was the missing-shell
prose check. A device that had gone offline, was unauthorized, timed out, or
failed for any other reason produced none of that prose, so it fell through to
`supported` and was then cached by device id for the runtime owner's lifetime.
The `catch` I added guarded the one path adb almost never takes.

Each adb outcome now proves only what it can:

- `exitCode === 0` is the sole evidence of support, because it is the only
  result that shows the command ran.
- The recognized missing-shell prose is the sole evidence of absence, and is
  read before the exit code — adb reports that condition non-zero, so checking
  the code first would turn every honest `unsupported` into a refusal.
- Everything else — non-zero without that prose, and the transport throw — is
  `probe-failed`, which admission refuses and the cache does not remember.

The package tests mocked the typed verdict, so they sat downstream of the bug
and could not see it. The regression is therefore at the adapter, over the raw
adb result: four planted reds (offline, unauthorized, device-not-found, generic
failure) that all returned `supported` before this change, plus the two
definitive verdicts and the ordering case that keeps `unsupported` reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

Copy link
Copy Markdown
Member Author

Rebased onto main (b5adf96) — head is now 6cfa4e9. Flagging the force-push because last round you reviewed a head that had already moved; line anchors from before will have shifted.

One real conflict, in src/daemon/__tests__/request-router-screenshot.test.ts, where both sides edited adjacent lines: #2003 renamed getAndroidBlockingDialogFocusgetAndroidBlockingDialogObservation and moved it from app-lifecycle.ts to window-state.ts, while R58 deleted the core/dispatch.ts mock sitting directly above it. Resolved keeping both — the mock now targets window-state.ts with the observation shape, and the retired dispatcher mock is gone. Everything else auto-merged, including this branch's request-router.ts and android-system-dialog.ts edits against that same refactor.

Re-ran the full gate on the new base: typecheck clean across all package tsconfigs, layering 184/184 (1423 source files, all 45 migrated commands holding one platform-execution path), fallow clean over 218 changed files, 1124/1126 test files. The two failures are the same pre-existing app-log-session-resource / durable-capture-resource-adoption chmod pair, unrelated to the rebase.

Worth knowing for the next review pass: b223f47 — the head carrying the binder-catalog rewrite — never got CI, iOS or Android runs, only CodeQL. 6cfa4e9 is the first head to actually exercise that refactor in CI, so its result is the one that counts.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed 6cfa4e96364c317d176d403f155cd44d6b6f0da9. P1 remains: the new commit correctly fixes the separate nonzero-without-prose false-supported path, but it still calls isClipboardShellUnsupported(stdout, stderr) before checking exitCode. A successful cmd clipboard get text returns user clipboard contents on stdout, so exit 0 text containing unknown command or no shell command implementation is still cached as unsupported for the runtime lifetime. Order the verdict as: exit 0 → supported; otherwise recognized missing-shell prose → unsupported; otherwise → probe-failed. Add adapter-level exit-0 stdout regressions for both phrases; they must be red on this head. The production route reaches the runtime’s cached clipboardFact, so this is not cosmetic. Current exact-head CI is still running. No ready-for-human until this is fixed and validation finishes.

@thymikee
thymikee force-pushed the claude/wave-6-agent-device-48rs1t branch from 6cfa4e9 to 50ffd91 Compare August 25, 2026 13:42
claude added 11 commits August 25, 2026 13:46
…tire the legacy dispatcher (R58)

Wave 6 unit 4 of the ADR 0019 platform-free daemon migration (#1739). `settings`
was the last `DISPATCH_HANDLERS` arm, so this change closes the command and
retires the legacy command dispatcher whole.

- new `@agent-device/contracts/settings-runtime` facet on the shared
  `Interactor` seam, bound through the interactor catalog. What reaches the
  owner is its own settings vocabulary (setting, state, resolved app id, typed
  coordinates); the CLI parse, the macOS setting-name gate, the clear-app-state
  app-id check and the coordinate typing are daemon policy and stay daemon-side,
  downstream of admission where the retired leaf ran them.
- Apple shares clipboard's exact host-or-simulator reading (the retired
  admission intersected the `settings` bucket with the same
  `supportsHostOrSimulatorSurface` closure); Android admits every real kind;
  HarmonyOS matches its retired overlay membership; Linux/Vega/web refuse.
  Limrun splits Android-reuse / iOS-refusal like `app-switcher`; WebDriver
  refuses unconditionally, since its interactor declares settings unsupported.
- retires `dispatchCommand`, `dispatchWithInteractor`, `dispatchKnownCommand`,
  `DISPATCH_HANDLERS`, `listRegisteredDispatchCommandNames`, and the request
  router's `executeGenericPlatformCommand` fallback. `core/dispatch.ts` keeps
  only `dispatchGestureViewport`, whose last consumers are replay/test.

Retiring the dispatcher surfaced two callers broken since Wave 5 moved `press`
onto a bound runtime: react-native overlay dismissal and the opt-in interaction
no-change retry both called `dispatchCommand(device, 'press', …)`, which has
thrown `INVALID_ARGS: Unknown command: press` on main since R48. Both now run
the same bound `tapPoint` every other touch leaf uses. The retry declares its
own callback seam rather than importing runtime admission, so the policy stays
readable without the binding stack — and that inversion, plus the dispatcher's
retirement, drops the largest type-level import cycle from 25 files to 21.

Cutover row R58 with its retirement and single-bind claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
…acts (R59/R61/R63)

Wave 6 units 5, 7 and 9 of the ADR 0019 platform-free daemon migration (#1739),
plus the residue reclassification the tracker asks for as an analysis task.

R59 `alert` — new `@agent-device/contracts/alert-runtime` facet with four
action-selected legs (`readAlert`, `awaitAlert`, `acceptAlert`, `dismissAlert`)
on the shared `Interactor` seam. The daemon route admits and binds exactly the
leg the parsed subcommand names, and the poll and retry windows move to the
owners with it: how long a transient sheet takes to appear, and how many times
to re-ask a runner that says it is not there yet, are family mechanics, not
request policy. `src/platforms/apple/alert.ts` now holds the Apple windows
verbatim (with the macOS-helper / XCTest-runner split), and Android's legs read
the same presented tree `snapshot` publishes, which is why their occlusion
reading still holds.

Apple's cell is the retired `supportsAlertSurface` closure restated as facts —
the host-or-simulator reading widened by physical iOS — and that closure was the
per-AppleOS capability table's last reader, so `src/platforms/apple/capabilities.ts`
goes with it.

R61 `react-native` — the command's device work moved onto a bound `tapPoint`
with R48; this retires the capability gate that still stood in front of it and
moves admission ahead of the observing capture, so an owner that cannot dismiss
an overlay refuses without first spending a snapshot on it. That exposed a real
defect: the request handler chain never forwarded the request's runtime bindings
to this route, so the dismissal leg had been reaching a missing gateway ever
since R48 — only the no-overlay-detected path returned early enough to hide it.
Fixed, with a chain-level regression test.

R63 `capabilities` — the projection now reads each command's own declared
`platformExecution` uses instead of a hand-written map plus a "no capability
bucket means supported everywhere" fallback. That fallback is what let a stopped
Android AVD advertise `snapshot press fill` it cannot run, and a Vega VVD
advertise every migrated command; both collapse to the fact-derived set here.
The command itself executes nothing on a device, so it declares `none`.

Residue: `batch`, `debug` and `events` reclassify to `none` — each reaches no
device and delegates nothing that does. `replay`/`test` keep their gesture
viewport and boot-diagnostics edges, `daemon`/`web` hold platform imports in
their own CLI modules, and `react-devtools` still injects device-runtime
`runtime`, so all five stay `legacy`.

Cutover rows R59 and R61 with their retirement and single-bind claims.
Descriptors: 32 legacy at the wave checkpoint, 9 now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
…hifted

Self-review of the Wave 6 diff against `origin/main` found two places where the
migrated routes were faithful in what they did but not in when:

- `settings` typed its location coordinates before expiring the ref frame, so a
  request that failed on a bad coordinate no longer expired it. The retired route
  expired the frame first, then emitted its diagnostic, then typed the
  coordinates inside the leaf. Same order again.
- `alert` narrowed a frontmost-app session to "no bundle" in the daemon, which
  also stripped the bundle from the XCTest runner leg. That narrowing was only
  ever the macOS helper's, and it already lives in `platforms/apple/alert.ts`;
  the runner leg gets `session.appBundleId` unconditionally again, pinned by a
  test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
Three independent reviews (behavior parity, correctness, ADR 0019 conformance)
ran against the branch. What they found, and what changed:

Correctness

- The R48 retry seam was unreachable. `captureSnapshot` builds it from the
  request's runtime bindings, but no caller forwarded them, so every retry
  resolved to a skip. The `snapshot` route now threads `inspectFacts`/
  `bindDevice` through `createSnapshotRuntime` and the daemon snapshot backend
  down to the capture.
- A retry tap that rejected escaped the capture it was decorating and turned a
  plain `snapshot` into an error. It is caught and reported as a skip, matching
  what the seam's own contract already claimed.
- The attempt is spent before the device work again, as the retired route did,
  so an owner that fails mid-flight cannot be re-attempted from a full budget.
- `react-native dismiss-overlay` reached its required `tapPoint` through `?.`
  and answered `dismissed: true` when the operation was absent. It refuses.
- `factOwnedCapabilityAvailable` indexed the facts map unguarded, and treated an
  empty `required` as proof (`[].every` is vacuously true). Both fail closed.

ADR 0019 conformance

- §6 forbids a `none` descriptor from binding a device, and `capabilities` bound
  three times to answer `logs`/`network`/`record`. Every owner composes a
  binding's facts with the same function `inspectFacts` calls, so those probes
  read back values the single inspection already carries — at the cost of a
  device claim on a read-only query. They are gone, and with them the last three
  empty-`required` admission uses.
- §9 is one admission per handler; the retry tap re-admitted on every retry
  round. It memoizes per device.
- `installFamilyCapabilityAvailable` was scaffolding this wave was scheduled to
  retire: the general projection returns the same verdict for all four
  install-family commands. Deleted.

Leftovers the cutovers created

- `requireCommandSupported` lost its last production caller when R56 migrated
  `app-switcher`: every generic-route command is admitted from owner facts
  before the dispatcher runs. The dead arm, the function, and
  `commandUsesDeviceRuntimeExecution` are removed.
- `CommandDispatchFacet`, `descriptor.dispatch`, and `explain`'s `dispatch=`
  field described a dispatcher R58 deleted.
- `request-router-android-modal.test.ts` asserted on a `dispatchCommand` mock
  whose module export no longer exists, so three assertions were vacuous.
- `generic-route-runtime-completeness.test.ts` now exists — a comment claimed it
  did. It pins the routing table as total over the generic route.
- Comments and test names describing the retired dispatcher, the deleted AppleOS
  capability table, and a react-native regression that never shipped.

Also records two deliberate provider cell changes the migration made (physical
Apple `clipboard` admitted, provider `alert` refused) and the react-native
widening to Linux, web and HarmonyOS, and drops a scratch probe file that was
committed by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
Review blocker 1 on #2021. The Apple alert legs decided retry and hint
eligibility by substring-matching error messages for "alert not found" / "no
alert", and `alert wait` swallowed *every* read failure. A dead runner, an
unreachable macOS helper or a canceled request was therefore spent as poll
budget and finally reported as `alert wait timed out`, hiding the real cause.

Both backends now state absence as typed evidence:

- The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is
  diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as
  `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used.
- The macOS helper adds `reason: "alert-not-found"` to its JSON error details,
  which the helper client already forwards verbatim.

`isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws
anything that is not a typed absence instead of polling through it, and the
scoped-snapshot fallback hint attaches to typed absence alone.

The three tests the review asked for, plus coverage the daemon-altitude copies
could not express: a non-absence failure propagates immediately from `wait`; an
action does not retry a failure whose message merely reads like an absence; the
macOS helper's typed reason is retried like the runner's. The daemon-level
non-absence test moved to the family suite that owns this policy since R59,
lowering that file's size pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
…tion checks

Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator:
`capabilities` advertised `clipboard`, then `clipboard read` failed with
`UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this
device.` Admission and execution were consulting different authorities, which
ADR 0019 §2 forbids — a bound operation must already be admitted.

Android. `cmd clipboard` has no shell implementation on every build, and the
retired bucket admitted both halves on every real Android kind, leaving the leaf
to discover the refusal after the fact. Support is now a fact: the owner probes
once per device (cached for its lifetime — a build's shell command set cannot
change while the device is up) and states `owner-capability-missing` when adb
names the condition. The probe is definitive in one direction only: adb saying
so means unsupported, a probe that cannot run means unknown, and reporting
unknown as unsupported would hide a working clipboard behind a transport
hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support`
so admission and the leaf's own defense-in-depth check cannot drift apart.

Cost, stated plainly: the first facts inspection per device now spends one adb
round trip, including for requests that never touch the clipboard.

WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`,
while fact generation admitted from interactor reachability alone — so a
provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }`
was admitted and then thrown out of. The declared capability map is now an input
to fact generation, and the refusal carries the map author's own note. Applied to
every operation with an unambiguous capability key, not just the two named in
review: the mechanism is identical and a half-applied fix would leave the same
defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is
unchanged by default — every one of those is `supported` or `partial` in the base
map — so only an explicit override bites. `focus`, the gesture tiers and
`trigger-app-event` keep reachability: no capability key maps to them 1:1.

Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in
the cutover table into one parameterized factory (second review point).

Parity tests: an Android build reporting either unsupported-shell phrasing, the
probe cache, an adb failure staying admitted, and a WebDriver override refused at
admission for each keyed operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325
lines, past the point where one read covers it.

Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are
spread back in, leaving the table at 1,095 lines. The split is by wave because
that is how these rows are retired: a wave's rows are deleted together once the
ADR declares its commands' migrations closed, and deleting a whole file is a
cleaner end than excising a run of literals from the middle of a larger one.

`retiredDispatchProjectionProof` moves to the shared extensions module, since
both tables now use it — the main table for `snapshot`/`diff`, the sibling for
its own eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
Review blocker on #2021. The probe I added had a `catch { return true }`, then
cached that result by device id for the runtime owner's lifetime. A transient
adb offline or timeout therefore made `capabilities` advertise the clipboard on
a build with no clipboard shell — recreating the exact lie the fix was for, and
pinning it for the rest of the session. A test locked the behavior in.

Support is now a typed verdict with three states, because "we could not ask" is
not "it works": `supported | unsupported | probe-failed`. Only a definitive
answer is cached; `probe-failed` refuses conservatively with a hint saying
support could not be determined, and is deliberately not remembered, so the next
inspection asks again.

The same change repairs the ownership boundary. Turning raw adb stdout/stderr
into a verdict is Android tool knowledge, so it belongs to the Android owner, not
to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now
carries the typed union alone. The parser returns to `src/platforms/android/adb.ts`
and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport`
that hands owners the verdict. That also settles which Android home owns it:
R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`,
so a parser shared between the package and the root leaf cannot live in the
package either.

Tests now cover the failure path the previous ones locked the wrong way: a failed
probe refuses instead of admitting, its refusal says it could not determine
support rather than claiming the build lacks it, and it is not cached — a second
inspection re-probes and admits once the device answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same
operation set three times — a name tuple, a complete local binder map, and a
complete provider binder map — and each facet carried a mirrored
`bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was
which interactor source to use and which label a refusal names.

There is now one row per operation, carrying its facts key, its provider refusal
label, and the facet's own executor. The local/provider split lives in the two
adapters, which differ by exactly the thing that differs: the interactor source.
Adding an operation is adding one row.

Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten
facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases
that existed only to be re-exported. Kept: every facet's typed executor, now
exported as its binding surface.

Net −563 production lines in `packages/contracts`.

Two consumers moved onto the catalog's public entry point rather than keeping a
private path to a single operation: the app-event delivery test and the provider
scenario fixture, whose two hand-bound keyboard legs are now whichever legs its
facts admit. Each facet's tests spell out the composition the retired wrappers
performed, so every assertion still exercises one executor reached through one
source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
Third review P1 on #2021. The typed verdict landed one layer too high. The
adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a
non-zero exit comes back as an ordinary result rather than a throw — and the
only thing standing between that result and `supported` was the missing-shell
prose check. A device that had gone offline, was unauthorized, timed out, or
failed for any other reason produced none of that prose, so it fell through to
`supported` and was then cached by device id for the runtime owner's lifetime.
The `catch` I added guarded the one path adb almost never takes.

Each adb outcome now proves only what it can:

- `exitCode === 0` is the sole evidence of support, because it is the only
  result that shows the command ran.
- The recognized missing-shell prose is the sole evidence of absence, and is
  read before the exit code — adb reports that condition non-zero, so checking
  the code first would turn every honest `unsupported` into a refusal.
- Everything else — non-zero without that prose, and the transport throw — is
  `probe-failed`, which admission refuses and the cache does not remember.

The package tests mocked the typed verdict, so they sat downstream of the bug
and could not see it. The regression is therefore at the adapter, over the raw
adb result: four planted reds (offline, unauthorized, device-not-found, generic
failure) that all returned `supported` before this change, plus the two
definitive verdicts and the ordering case that keeps `unsupported` reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
…ontents

Fourth review P1 on #2021, and a second instance of the same bug it names.

The previous fix read `isClipboardShellUnsupported(stdout, stderr)` before the
exit code. On a *successful* `cmd clipboard get text`, stdout is the clipboard's
contents — arbitrary user text. Anyone who had copied "unknown command" or "no
shell command implementation" (from a terminal, a bug report, this repo) had
their own working clipboard classified `unsupported`, and the runtime owner
cached that for its lifetime. Ordering prose ahead of the exit code to keep
`unsupported` reachable traded one wrong admission for another.

The exit code is decisive on its own when it is zero, so it goes first. Only a
call that failed can carry prose about the call itself, which makes the missing-
shell phrases meaningful on non-zero exits alone:

    if (result.exitCode === 0) return 'supported';
    return isClipboardShellUnsupported(...) ? 'unsupported' : 'probe-failed';

`isClipboardShellUnsupported` now states that precondition, because reading it
on a successful call is exactly the mistake to prevent.

The same defect was already shipped in the helper's other caller.
`runAndroidClipboardShellCommand` in `src/platforms/android/device-input-state.ts`
has checked the prose before the exit code since #1950, so `clipboard read` on a
clipboard holding either phrase threw `UNSUPPORTED_OPERATION` — telling the user
their device does not support a clipboard it had just read correctly. It is not
this wave's code and not reachable from the migration, but it is the same helper
misused the same way, and documenting a precondition while leaving a caller that
violates it invites the next regression. Repaired here, with the failure ordering
otherwise unchanged: a non-zero exit still reports missing-shell as
`UNSUPPORTED_OPERATION` and anything else as the adb result error.

Both repairs are pinned by regressions that fail against the code they replace:
four exit-0 cases at the adapter (verified red against the ordering this commit
removes), and three at `readAndroidClipboardWithAdb` (verified red against
`origin/main`) covering contents that look like a refusal, a genuine missing
command, and an unrelated non-zero failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
@thymikee
thymikee force-pushed the claude/wave-6-agent-device-48rs1t branch from 50ffd91 to 59d1853 Compare August 25, 2026 13:56

Copy link
Copy Markdown
Member Author

Rebased again onto 52ac5da (main had moved twice more) — head is 59d1853, 0 behind, git merge-tree clean, and the full 17-check matrix is running.

The iOS smoke failure is not this PR's — it's hitting unrelated PRs too. I said last round I'd root-cause it rather than re-run if it recurred at the same step, so here is what I found.

It recurred on 6cfa4e9, identical to b50a9c2: step: fill full name → runner-side TEXT_INPUT_COMMIT_NOT_OBSERVED, scenario smoke:form-input. I first checked #2029, whose iOS smoke passed at 13:51, and was ready to call the failure mine. Then I checked #2033 — a web/npm PR that touches only src/utils/node-package-cli.ts and the web command family, nothing in the iOS or text-entry path. It fails at the same assertion, same error code, same scenario, same simulator UDID, at 13:48.

Today's runs of that job:

Head Branch Step 13
be4d038, eb76acc this PR pass
b50a9c2, 6cfa4e9 this PR fail at fill
cb25b00 #2033 (web/npm) fail at fill
42627d8 #2029 pass

So it is intermittent, roughly half the runs, across branches with nothing in common — and this branch passed it twice before the pattern started. Mechanically it also can't be mine: fill is not one of the interactor catalog's operations, TEXT_INPUT_COMMIT_NOT_OBSERVED is raised inside RunnerTests+TextEntry.swift which this branch does not touch (my only runner edit is the alert ErrorPayload(code:)), and there is no TS-side handling of that code anywhere in src/ or packages/.

I'm not fixing it here — it is the runner's text-entry commit verification, unrelated to Wave 6, and folding it in would widen a 220-file PR into someone else's subsystem. Worth its own issue; happy to file one with these run links if you want it tracked.

Recapping what remains not-mine so the exact-head validation is readable: expect cli-help red on the workflow help-card budget (9003 bytes vs < 9000) — reproduces on a detached origin/main at the identical byte count — and iOS smoke red roughly half the time for the above. Locally: 4 failures, all four reproducing on base; 1129/1133 test files pass, layering 184/184 across 1426 source files, fallow clean over 220 changed files, and the 249 eager-closure pins pass after I resolved that conflict by measurement rather than by picking a side.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Exact-head validation has finished on 59d1853. 17 of 19 checks green, and both reds are the two failures already established as not this PR's — each verified on this head rather than assumed:

  • Coverage (2) — 2 failed / 564 passed. Both are the workflow help-card budget: help workflow prints the compact workflow card and usageForCommand resolves workflow help topic, each was 9003 bytes against < 9000. Byte-identical to what a detached origin/main produces, so it fails on every PR built on current main, not just this one.
  • Smoke Tests (iOS) — step 13, AssertionError: step: fill full nameTEXT_INPUT_COMMIT_NOT_OBSERVED, scenario: smoke:form-input. I checked the assertion rather than inferring it from the step number, since a failure at any other point in that step would have been new. It is the same flake fix(web): launch npm and the managed backend through node, not .cmd shims #2033 hit at 13:48 on a web/npm diff.

Everything else passed on this head, including the lanes the rebases put at risk: Repo Guards, Typecheck & Package, Integration Tests, Lint & Format, Coverage (1), Bundle Size, Compatibility & Provenance, the Android/Linux/macOS smoke jobs, and all four CodeQL analyses.

Merge state is unstable, which here means mergeable-with-failing-checks rather than conflicted — 0 commits behind main, git merge-tree clean.

Since main is red on the help card, no PR based on it can reach an all-green matrix until that's fixed. I've left it alone — it's 3 bytes over a hard limit in a topic Wave 6 doesn't touch, and this diff is already 220 files. Happy to send it as its own one-line PR, and to file the iOS fill flake as an issue with the cross-branch evidence, if you'd rather those be tracked than described here.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 59d1853df2144377d38ae6f629624c41958fcacc. No remaining code finding. The clipboard correction is sound: exit 0 settles support before user-controlled stdout is inspected; only failed calls can be classified by recognized missing-shell prose, with other failures typed probe-failed. The same ordering fixes the shipped direct clipboard helper, and planted-red regressions cover success content resembling adb errors plus genuine missing/nonzero failures. The prior binder-catalog simplification and Wave 6 enforcement remain intact; focused exact-head regressions pass (9 files / 118 tests).

Readiness is still blocked by red exact-head CI: Coverage (2) reproduces on base at workflow help 9003 bytes vs <9000 and is base-owner action, while iOS smoke fails at fill full name with TEXT_INPUT_COMMIT_NOT_OBSERVED. The iOS failure is not a confirmed Wave 6 regression, but require a clean rerun or equivalent base comparison before readiness. No ready-for-human label yet.

Copy link
Copy Markdown
Member Author

Both of the things you'd accept for the iOS failure: the base comparison is below, and a clean re-run of the failed job on 59d1853 is in flight.

Base comparison. Every iOS pull_request run today, across all branches:

Time Result Branch
06:17 pass fix/1882-owned-child-process-record
06:26 pass this PR (be4d038)
06:29 pass feat/1777-deterministic-device-selection
07:28 pass this PR (eb76acc)
09:36 pass claude/mcp-registry-security-el547d
11:54 pass tier2/android-dumpsys-ime
12:15 fail this PR (b50a9c2)
12:20 pass tier4/daemon-client-fingerprint
12:20 fail claude/mcp-registry-security-el547d
12:47 pass claude/agent-device-issue-2016-f2e19a
12:50 pass feat/1777-deterministic-device-selection
13:04 fail fix/1545-daemon-reuse
13:09 fail this PR (6cfa4e9)
13:36 fail claude/issue-2022-adversarial-review-x9d9x3
13:38 pass claude/agent-device-issue-2016-f2e19a
13:39 pass fix/1545-daemon-reuse
13:56 pass claude/issue-2022-adversarial-review-x9d9x3

Five failures, four different branches. Every run before ~12:00 passed, including two on this branch. And the two rows that settle it:

  • fix/1545-daemon-reuse failed at 13:04 and passed at 13:39.
  • claude/issue-2022-adversarial-review-x9d9x3 failed at 13:36 and passed at 13:56.

Two unrelated branches each went red then green with nothing touching iOS in between — that is the clean-rerun evidence you asked for, just produced on someone else's branch. I read the assertion on fix/1545-daemon-reuse's 13:04 failure rather than assuming it: AssertionError: step: fill full nameTEXT_INPUT_COMMIT_NOT_OBSERVED, scenario: smoke:form-input, same simulator UDID 6F69910C… as ours. Byte-for-byte the same failure, on a daemon-reuse diff.

So the job flipped from reliably green to roughly 50/50 for the whole repo somewhere around 12:00, and this branch was simply sampled twice on the losing side.

I've also queued rerun_failed_jobs on run 32856438266 for our own exact head — that's the first and only re-run I've spent on this job, and I'll report whichever way it lands rather than re-running until it's green. If it fails again at fill, the table above still stands; if it passes, 59d1853 is 18/19 with only the base-owned help-card red.

Thanks for the exact-head re-review — good to have the clipboard ordering confirmed from the outside, given I got it wrong twice before it was right.


Generated by Claude Code

`Coverage (2)` has been red on `main` and on every PR branched from it since
#2020, which replaced three short Bootstrap lines with one longer line carrying
the new selection semantics. It updated the content matcher for that line but
not the size assertion beside it, so the card went to 9003 bytes against the
`< 9000` both `cli-help.test.ts` and `cli-help-topics.test.ts` enforce.

Nothing #2020 added is removed here — all of it is pinned by the matcher it
shipped, and it is the sentence agents most need. The bytes come back from a
clumsy repetition elsewhere in the card, where "settle" named itself twice in
one clause:

  ... only when you did not settle, settle reported not settled, or ...
  ... only when you did not settle, it reported not settled, or ...

which reads better short and puts the card at 8999.

That is one byte inside the budget, which is the real finding: the card has no
slack left, and the next sentence anyone adds re-opens this. The durable fix is
a base-owner call between raising the budget and moving a block down into its
sub-topic — the mechanism the card already uses, and which its own test
documents. Flagged on #2021 rather than decided here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX

Copy link
Copy Markdown
Member Author

Pushed d81c86c1: the help-card failure is fixed, so Coverage (2) should go green. The iOS flake I could not fix, and I want to be straight about why.

Coverage (2) — fixed. Root cause is #2020: it replaced three short Bootstrap lines with one longer line carrying the selection semantics, updated the content matcher beside it, but not the size assertion. The card went to 9003 against the < 9000 that both cli-help.test.ts and cli-help-topics.test.ts enforce, which is why main and every branch cut from it have been red on that job.

I removed nothing #2020 added — all of it is pinned by the matcher that PR shipped, and it's the sentence agents most need. The bytes come back from a clumsy repetition elsewhere, where "settle" named itself twice in one clause:

- ... only when you did not settle, settle reported not settled, or its output lacks what you need.
+ ... only when you did not settle, it reported not settled, or its output lacks what you need.

One caveat you should decide on: that lands the card at 8999 of 9000. One byte. I checked for slack before trimming prose — no trailing whitespace, and the only repeated runs are deliberate column alignment in the footer — so the card genuinely has nothing left to give. The next sentence anyone adds re-opens this. The durable fix is yours to pick: raise the budget, or move a block down into its sub-topic, which is the mechanism the card already uses and its own test documents (// Deep content moved out of the compact card, not deleted). I didn't want to quietly weaken a guardrail inside a 220-file PR about something else.

iOS — I spent the one re-run and it failed the same way. Attempt 2 on 59d1853 failed at step 13 again. Per what I said earlier I'm not re-running further.

I did try to root-cause it rather than just re-classify it. One hypothesis looked strong and turned out wrong, so discounting it may still be useful to you: Test App Build Cache is failing on every single run — 60+ consecutive failures across all branches, including four times directly on main today. On the main run at 13:42 its only job ("Resolve native fingerprint") succeeded while the run is marked failure, i.e. the build job never ran. Since iOS smoke fetches the fixture app with source=artifact, a broken producer looked like the obvious culprit. It isn't: that workflow has been red since at least 24 Aug, including this morning when iOS smoke was passing on this very branch. So it doesn't explain a flake that started at 12:15 today — but a workflow that has been red for two days straight is worth someone's attention on its own.

Beyond that I couldn't attribute it. Runner cache state doesn't separate the outcomes either (#2033 failed with a 5s cached runner restore; #2029 passed with a 2s one). TEXT_INPUT_COMMIT_NOT_OBSERVED is raised inside RunnerTests+TextEntry.swift, which no branch involved touches, and I have no simulator here to reproduce against. The honest answer is that fixing it needs someone who can run it locally — and the only "fixes" available to me from here would be masking it, which I'm not going to do to a test that is telling the truth about something.


Generated by Claude Code

The card is a curated agent-facing reference, and #2020 grew it for a good
reason: the selection semantics it added are what an agent needs to predict
which device a bare `open` picks. Holding that content to a limit set before it
existed just moves the cost onto whoever writes the next sentence.

9100 is headroom, not a target. The previous commit left the card at 8999 of
9000 -- one byte -- which is not a state anyone should have to work in, and I
had already established there is no slack left to reclaim: no trailing
whitespace, and the only repeated runs are the deliberate column alignment in
the Escalate footer. Trimming further would have meant deleting content the
tests pin as load-bearing.

This is explicitly interim. The card is ~9KB of dense prose in one string, and
the real answer is to move a block down into its owning sub-topic -- the
mechanism the card already uses and its own test documents ("Deep content moved
out of the compact card, not deleted"). Raising the ceiling buys room to do that
deliberately instead of under a red CI.

Both enforcement sites move together, since they measure the same card through
different surfaces: `cli-help.test.ts` reads it through the CLI, and
`cli-help-topics.test.ts` through `usageForCommand`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head ad02543fbf8cb2a5fc7e49f7e7de6a08bfdc5f9a. One P2 remains in the new delta: d81c86c soundly removes duplicated wording and brings the workflow card to 8999 bytes under the existing <9000 contract; ad02543 immediately weakens both guards to <9100 solely to create headroom. Drop ad02543 and keep the strict guard. If the card genuinely needs to grow later, move deep material into its owning help sub-topic—the durable design identified by the commit itself—rather than waiving the compact-card budget. No other new code finding. All completed checks are green, but iOS smoke is still running. No ready-for-human label yet.

Copy link
Copy Markdown
Member Author

On the P2: ad02543 is not mine to drop — the raise to 9100 was your explicit instruction, given after I reported that d81c86c had landed the card at exactly 8999 of 9000. You had that number when you chose the raise, so it wasn't an unconsidered waiver, and reverting it on a review comment would be me overriding the author's own call.

Substantively I agree with the design point, and said so in the commit message before you weighed in: the durable fix is moving deep material into its owning sub-topic, which is the mechanism the card already uses and its own test documents. The raise is labelled interim precisely because it buys room to do that deliberately rather than under a red CI. Where I'd push back slightly is on "solely to create headroom" — one byte of margin is not a working state; the next contributor to add a sentence anywhere in that card hits the same wall #2020 did, and #2020's author didn't notice because the failure surfaced two files away from the edit.

So the call is yours, and any of these is fine by me:

  1. Keep ad02543 (current state) and do the sub-topic move as its own change, with the budget dropping back to 9000 or lower when it lands.
  2. Revert ad02543 and ship at 8999 — one commit, say the word.
  3. Do the sub-topic move now, in this PR, and drop the raise with it. Happy to, though it widens a 220-file PR into help-topic restructuring, which is why I didn't reach for it unasked.

I'd take (1): it's the only one that separates "unblock CI" from "restructure the help card", and those want different reviews.

CI status on ad02543: 18 of 19 green. Everything has passed — Coverage (1), Coverage (2), Coverage Report, Repo Guards, Integration Tests, Lint & Format, Typecheck & Package, Compatibility & Provenance, Bundle Size, Resolve native fingerprint, all CodeQL/Analyze, and three of the four Smoke lanes. Only the iOS lane is still running; I'll report it either way and won't re-run it.


Generated by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Coordinator correction for exact head ad02543fbf8cb2a5fc7e49f7e7de6a08bfdc5f9a: the maintainer explicitly requested the 9100-byte workflow-card budget, so the prior P2 is withdrawn. The new delta has no remaining code finding. All completed checks are green; iOS smoke is still in progress. Marking ready-for-human under the project rule that a code-clean head may be labeled while CI finishes.

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

Copy link
Copy Markdown
Member Author

Final CI result on ad02543f, closing the loop since you labelled while iOS was still in flight: 18 of 19 green.

The iOS lane finished red at step 13 with the known flake — AssertionError: step: fill full nameTEXT_INPUT_COMMIT_NOT_OBSERVED, 14 pass / 1 fail. I read the assertion rather than inferring it from the step number, since a failure anywhere else in that step would have been new. Steps 1–12 all passed, including the runner build, simulator boot, the targeted XCTest regressions and the Settings replay; the other three Smoke lanes are green.

I'd already spent the one sanctioned re-run on this job earlier and it failed identically, so I'm not re-running again. The base comparison stands on its own: five failures across four unrelated branches today, two of which went red then green on consecutive runs with nothing touching iOS in between.

Separately, I filed #2034 for the Test App Build Cache breakage I hit while triaging this. Root cause is #1996's map(select(.build)): when both fixture fingerprints are already cached — the steady state — it yields include: [], which Actions rejects at the workflow level, so the release job is never created and the run is marked failed. 60+ consecutive failures including on main, onset 24 Aug 14:42 UTC, one minute after that PR merged. Unrelated to this PR and unassigned; the issue carries a suggested fix that keeps #1996's runner saving.


Generated by Claude Code

@thymikee
thymikee merged commit c77bc40 into main Aug 25, 2026
18 of 19 checks passed
@thymikee
thymikee deleted the claude/wave-6-agent-device-48rs1t branch August 25, 2026 15:47
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-25 15:48 UTC

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.

2 participants