Repository navigation
feat: cancel timed-out tasks; late ref writes cannot overwrite newer refs - #64
Conversation
de9777c to
5131d82
Compare
5131d82 to
69cf663
Compare
69cf663 to
7d0c565
Compare
|
CodeQL js/resource-exhaustion on |
…rwrite
A request that timed out kept running: it could still click, snapshot and
then overwrite the refMap the next request relied on. Refs were renumbered
by every observation and discovery call, so refs an agent held could point
at other elements. On mobile, a click observation could show the old screen.
- RequestQueue passes an AbortSignal to each task and aborts it on timeout.
ToolContext gains optional `signal` and `setRefMap`. Interaction tools,
run_steps, discovery tools and the observation check the signal between
steps (MM_REQUEST_ABORTED, internal).
- New RefStore (server-owned, wraps sessionManager.setRefMap): each request
takes a generation; writes from an older or aborted request are dropped.
Tools that call context.sessionManager.setRefMap go through it too (proxy).
ISessionManager is unchanged.
- Stable refs: discovery tools and observations reuse the previous ref for
the same element (browser key: role + name + testId; mobile: testId, else
role + name; elements sharing a key are matched in screen order).
ToolContext.setRefMap returns the stabilized { refMap, nodes }, which
describe_screen / accessibility_snapshot return. New elements get refs
above a session high-water mark, so a removed ref is never reused until
launch/cleanup. When a tool set refs, the observation merges into them.
- list_testids no longer reads the a11y tree or sets refs (it returns none).
- Mobile observation and describe_screen: one device snapshot for both the
tree and test IDs (optional IPlatformDriver.captureScreen; ScreenCapture
type exported). After a mutating or batch call, re-snapshot every 300 ms
while the screen is busy (", busy" label, progress/activity indicator), at
most 3 times / 2 s; an unchanged screen gets one retry.
- The observation timeout now aborts the observation instead of leaving it
running.
CodeQL (js/resource-exhaustion) flagged the queue timer because its duration comes from the request body. Clamp it to MAX_QUEUE_TIMEOUT_MS.
Ref renumbering (stabilizeRefs, the session high-water mark, baseline checks, merging tool and observation refs) solved a problem that rarely happens: refs are already deterministic per tree, so an unchanged screen keeps its refs and diffs as unchanged, and read commands no longer collect observations (P1-05/P1-06). When elements are inserted above an element, the diff (or the full tree) carries the new refs. Kept: - AbortSignal per queued task, and RefStore as a generation guard: a write from a timed-out or older request never reaches the session manager (`context.sessionManager.setRefMap` and `clearRefMap` go through it too). - lastObservation only when delivered. - Mobile settle after a mutating tool, now compared against the last tree the agent received (`lastScreen`: the delivered observation, else the describe_screen / full accessibility_snapshot tree). No settle when a non-batch tool set refs (it just read the screen). Changed: the observation's refMap always wins, also after a tool set refs (describe_screen --observe), so every ref in the last tree of a response resolves. Removed: ref-stability.ts, ToolContext.setRefMap, setToolRefMap, the RefStore baseline / high-water mark / key modes.
6944868 to
674ef64
Compare
| const aborted = abortedResponse<RunStepsResult>(context); | ||
| if (aborted) { | ||
| return aborted; | ||
| } |
There was a problem hiding this comment.
batchTimeoutMs allows up to 300 s, but the queue budget only comes from timeoutMs, which run_steps doesn't have. So batches get the 30 s default, and the CLI also uses 30 s. Before, a batch that hit it kept running in the background. With this check, it's now cut off at the next step. Should the queue and CLI budgets follow batchTimeoutMs, or should its max be lowered to about 30 s?
There was a problem hiding this comment.
Thanks. We went with the lower max: batchTimeoutMs is now 1000-30000 (#71, fix!: cap run_steps batchTimeoutMs at 30 s), like the cdp timeoutMs. The schema text and docs say the deadline is checked between steps, so to get partial results it should be below the 30 s request limit (e.g. 20000). Longer flows can be split into several batches.
| if (aborted) { | ||
| return { error: aborted }; | ||
| } | ||
|
|
There was a problem hiding this comment.
This check runs at the start of the click, before the queue timer could have fired, so it possibly almost never triggers. Inside run_steps, batch.ts already checks between steps. The slow part, waiting for the target, never sees the signal, so a timed-out click can still happen later.
Example, not sure if could happen in reality, but may be in slow devices?
0 s mm click --testid confirm-button → checked here: not aborted, starts waiting for the target
30 s queue timeout → agent gets "timed out"; signal aborted, but nothing checks it
35 s queue settle wait ends → agent's next command (describe-screen) starts
38 s target found → the click happens anyway, while the agent thinks it didn't
Suggestion: could driver.click and driver.type take an optional signal and call signal?.throwIfAborted() after the target wait, right before the tap or click? Both drivers already split wait and action, so it's a one-line check per driver, and it stops a timed-out click from landing later.
// src/platform/types.ts
click(
targetType: TargetType,
targetValue: string,
refMap: Map<string, string>,
timeoutMs: number,
within?: WithinScope,
signal?: AbortSignal,
): Promise<ClickActionResult>;and then each driver checks it right before the action:
// playwright-driver.ts, after waitForTarget(...)
signal?.throwIfAborted();
await locator.click({ timeout: remaining });
// mobile-platform-driver.ts, after waitForElement(...)
await this.#backend.waitForElement(query, timeoutMs);
signal?.throwIfAborted();
await withTimeout(this.#backend.tapElement(query), remaining, 'tapElement');There was a problem hiding this comment.
Agreed, fixed in #71 (fix: do not click or type after the request timed out) as you suggested: IPlatformDriver.click/type take an optional signal, and both drivers call signal?.throwIfAborted() after the target wait, right before the action (mobile type also between the focus tap and typeText). The tools pass context.signal and return MM_REQUEST_ABORTED. Verified on the extension: a run_steps click whose target appears 1 s after the 30 s queue timeout clicked before, and does not click now. Limit (documented): an action that already started is not stopped. device-mcp's waitForElement/tapElement/typeText take no signal, and Playwright's actionability checks are part of locator.click. Cancelling inside device-mcp needs a device-mcp change, kept as a follow-up.
> **Summary:** Let each tool call choose `observe: 'none' | 'diff' | 'full'`. Remove app state from observations and stop the mobile driver from inventing wallet-state values. **Stack context:** mm CLI v2, Phase 1, **P1-06 + P1-07**, combined at the shared observation boundary. Base: `cryptotavares/mmcli-p1-05-observe-mutating-only`; review [#61](#61) first. Followed by [#64](#64). ## Problem Tool categories fix the observation policy. Callers cannot skip a snapshot while chaining clicks or request one after a read. `run_steps` uses a separate `includeObservations` policy. Observations also combine screen data with app state. Mobile returns guessed wallet values, including `isUnlocked: true` and `currentScreen: "unknown"`. Browser observation collection reads extension state and can spend another 500 ms rechecking an unknown screen. These concerns belong at different boundaries: the daemon controls observations, while `get_state` reports platform state. ## Solution Add one shared observation field to registered tool schemas. Consume it in the daemon, outside the tool-handler contract. ```mermaid flowchart TD A[CLI --observe or HTTP observe] --> B[Validate the shared observe field] B --> C[Strip observe before handler dispatch] C --> D[Execute the tool or batch] D --> E[Resolve explicit level or default] E -->|none or no active session| F[Return the tool response] E -->|diff or full with active session| G[Collect test IDs and a11y only] G --> H[Compact options; full skips diff] H --> F classDef new fill:#fff3cd,stroke:#d39e00 class A,B,C,E,G,H new ``` Yellow boxes are new. `withObserve()` preserves existing schema refinements and defaults through intersections. Both HTTP and batch dispatch call `omitObserve()` before the handler. Consumer launch implementations therefore receive their existing input shape. For `run_steps`, the top-level level wins. Otherwise, the last executed step's `args.observe` wins, then `diff`. Timeout-skipped steps do not count. Earlier per-step levels do not trigger separate observations. Observation collection reads only test IDs and accessibility nodes. It no longer calls `getExtensionState()` or `getAppState()`. Browser collection waits for page settling for mutating, batch, and CDP calls. Read-only and discovery calls skip that wait. ## What changes | Condition or boundary | Before | After | |---|---|---| | Default tool call | Category controls collection; batch has a separate policy | `diff` for mutating tools and batches; `none` for read-only, discovery, and CDP tools | | Explicit `observe: 'none'` | No shared override | Skips collection and returns no observations; `observationMs` is zero | | Explicit `observe: 'diff'` or `'full'` with an active session | No shared override | `diff` uses the previous snapshot; `full` returns the whole option-collapsed tree without diff metadata | | CLI `--observe <level>` | Unsupported | Accepted anywhere; injected into POST `/tool/*`, `/launch`, and `/cleanup` object bodies; an existing body field wins | | `run_steps.includeObservations` | Accepts `all`, `none`, or `failures` | Fails validation with `includeObservations was removed; use observe` | | Observation payload | App state, test IDs, and a11y | Test IDs and a11y only; use `get_state` for app state | | Mobile `getAppState()` | Adds guessed wallet fields | Returns `{ isLoaded, extensionId, source: 'stub' }`; unread fields are omitted | For ordinary calls, a `full` observation becomes the next diff baseline; `none` leaves it unchanged. Existing reset tools still clear it: `describe_screen`, `accessibility_snapshot`, `launch`, and `cleanup`. An invalid CLI level exits with code 1 and prints: ```text Error: --observe requires one of: none, diff, full ``` ## Decisions for reviewers | Decision | Reason | |---|---| | Combine **P1-06** and **P1-07** | Observation selection and state removal change the same collection path. Keeping one boundary avoids separate platform policies. | | Consume `observe` outside handlers | The daemon owns collection. Shared validation accepts the field without passing it into consumer implementations. | | Collect once after a batch | The final screen supplies the batch observation. Top-level configuration takes precedence over the last executed step. | | Keep `full` compacted | Callers skip diffing but retain option collapsing. The existing discovery tools remain available for unfiltered trees. | | Mark mobile state as partial | Optional `ExtensionState` fields distinguish unread data from real false, empty, or null values. | | Keep the registered-function dispatch guard | The HTTP executor checks registry membership and callable type before dispatching a request-derived tool name. | ## Review guide Read the files in this order: | # | File | What to check | |---|---|---| | 1 | `src/validation/schemas.ts`, `src/tools/types/tool-inputs.ts` | Shared levels, schema intersections, removed batch policy, and `omitObserve`. | | 2 | `src/server/create-server.ts`, `src/tools/batch.ts`, `src/tools/registry.ts` | Override precedence, timeout-skipped steps, handler isolation, category defaults, and registered-function dispatch. | | 3 | `src/tools/types/observation.ts`, `src/server/observation-compaction.ts` | State-free snapshots, option compaction, full-versus-diff behavior, and baseline handling. | | 4 | `src/capabilities/types.ts`, `src/platform/mobile-platform-driver.ts` | Optional state fields and the mobile partial-state result. | | 5 | `src/cli/mm.ts` | Global parsing, exact error text, request-body injection, and body-field precedence. | | 6 | Changed tests, `README.md`, `SKILL.md` | HTTP and batch behavior, CLI injection, mobile state, and caller migration guidance. | <details> <summary>Testing</summary> - Fresh check at remote head `9f8803964119722b13c10e989be20aa5e035c0bc`: `CI=true yarn vitest run src/cli/mm.test.ts src/platform/mobile-platform-driver.test.ts src/server/create-server.test.ts src/server/observation-compaction.test.ts src/tools/batch.test.ts src/tools/registry.test.ts src/tools/types/observation.test.ts src/validation/schemas.test.ts --coverage.enabled=false --maxWorkers=2` passes: 8 files, 683 tests. Vitest reports no type errors. - `yarn lint` and the production type check, `yarn tsc --noEmit --emitDeclarationOnly false -p tsconfig.build.json`, pass. - Standalone `yarn tsc --noEmit -p tsconfig.json` fails with nine test-fixture diagnostics already present at this remote head. These affect Hermes, mobile backend mocks, a server test table, mock-network result narrowing, and a screenshot fixture. - The tests cover category defaults, explicit overrides, last-executed-step selection, stripped handler input, full/diff baselines, and state-free observations. - The `Proof` and `Unit tests` sections below retain the original historical evidence. These fresh checks do not repeat live platform validation. </details> ## Breaking changes - `run_steps.includeObservations` is removed. Sending it is a validation error (`includeObservations was removed; use observe`). The `'failures'` mode is gone. - Observations no longer contain `state`: - `StepRecordObservation` has no `state` field; - `createDefaultObservation(testIds, nodes)` drops its first parameter. - `ExtensionState`: `currentUrl`, `isUnlocked`, `currentScreen`, `accountAddress`, `networkName`, `chainId` and `balance` are optional. New field: `source?: 'stub'`. - `MobilePlatformDriver.getAppState()` returns `{ isLoaded, extensionId, source: 'stub' }` only. - `shouldCollectObservations` and `shouldIncludeObservationsInResponse` are replaced by `resolveObserveLevel(category, input, toolResult)`. - `toolSchemas` entries are now intersections that include `observe`. The individual `*InputSchema` exports are unchanged. ## Consumer impact | Consumer | Change needed | Done on branch | |---|---|---| | Extension `test/e2e/playwright/llm-workflow/` | None. Its own `ExtensionState` with required fields still type-checks against the looser core type, and nothing reads `observations.state` or `includeObservations`. Jest: 144 passed. | n/a | | Mobile `tests/llm-workflow/` | The state snapshot capability stops inventing `isUnlocked`/`currentScreen`/null fields: with no session it reports `{isLoaded:false, extensionId:'', source:'stub'}`, and it omits `currentScreen` when no known testId matches. Jest: 232 passed. | `cryptotavares/mm-cli-v2-mobile` 35fc72cc609 | Follow-up, not in this PR: on mobile, core `get_state` calls the driver directly, so the Mobile capability's screen detection only runs on browser. Phase 2 `mm state` (P2-09) covers this. ## Proof Tested with a yalc build of this branch in the Extension (e2e build, headless) and Mobile (dev client on iOS sim and Android emulator), on wallet home after unlock. Before = #61 tip; its proof is reused, except the Extension click bench, which was run on both builds back to back. **Same command at each level** (after; bytes / totalMs): | Platform | `none` | `diff` | `full` | |---|---|---|---| | ext (`click` a home tab) | **197** / 17 | 6760 / 581 | 9441 / 586 | | iOS (`wait-for wallet-screen`) | **183** / 880 | 7495 / 2923 | 58865 / 2905 | | Android (`click tab-bar-item-Wallet`) | **193** / 1794 | 7672 / 3671 | 1059191 / 3666 | | Check | Result | |---|---| | `click --observe none` | no `observations`, `observationMs` 0, on all 3 platforms | | `get-state --observe full` | observation with `testIds` + `a11y` only: no `state`, no `diff` | | `run-steps`, top-level `observe: none` / last step `observe: full` | no observations / full tree without `diff` (all 3 platforms) | | `run-steps` with `includeObservations` | exit 1: `includeObservations was removed; use observe` | | `observations.state` in any of the 55 after outputs | none | | Mobile `get-state`, before → after | `isUnlocked: true, currentScreen: "unknown", currentUrl: "", 4 × null` → `{isLoaded, extensionId, source: "stub"}` | | ext click `observationMs` p50 (bench, 5 runs, back to back) | 564 → 555 ms; bytes 7335 → 6760 (the `state` block) | | ext smoke `click-unlock` (unlock → home) `observationMs` | 71 → 15 ms (the re-check loop no longer runs while the screen is `unknown`) | The bench gain is small because on home the screen is already known, so the 500 ms re-check rarely ran. Most of the remaining ~550 ms is the animation wait plus the a11y snapshot. P1-10 targets the animation wait. <details> <summary>Transcripts (ext)</summary> ``` $ yarn mm click --testid unlock-submit --observe none { "clicked": true, "target": "testId:unlock-submit", "meta": { "timing": { "queueMs": 0, "toolMs": 32, "observationMs": 0, "totalMs": 33 } } } $ yarn mm run-steps '{"steps":[{"tool":"get_state"}],"includeObservations":"none"}' Error: includeObservations: includeObservations was removed; use observe exit=1 $ yarn mm --observe bogus get-state Error: --observe requires one of: none, diff, full exit=1 ``` </details> <details> <summary>Unit tests</summary> ``` Test Files 46 passed (46) Tests 1450 passed (1450) Type Errors no errors All files | 96.94 | 93.52 | 94.91 | 97.12 yarn lint → exit 0 ``` New or changed: - `resolveObserveLevel`: category defaults, an explicit level wins, an invalid level is ignored, `run_steps` last executed step and failure fallback. - Endpoint tests: `click` with `none`; `get_state` with `full`/`diff`; `cdp` with `full` waits for animations; `observe` is not passed to the handler; `/cleanup` body. - No `getExtensionState` during observation (spy). - Mobile `getAppState` shape. - `withObserve`/`omitObserve`, `includeObservations` rejected, and the CLI `--observe` parsing and injection. </details>
…e call (#65) > **Summary:** Reduce browser observation work with a bounded animation wait and one in-page testId collection call. Run both functions in a cached CDP isolated world, so Extension page-global restrictions do not break them. **Stack context:** mm CLI v2, Phase 1, **P1-10 + P1-12**, combined in one PR. Base: `cryptotavares/mmcli-p1-08-abort-refs`; review [#64](#64) first. Followed by [#66](#66). ## Problem Browser observations wait for every running animation, including spinners that never finish. The old main-world check also depends on globals that LavaMoat can restrict. Hidden tabs do not provide the animation frames that the check needs. TestId collection adds three Playwright calls per element: `getAttribute`, `isVisible`, and `textContent`. Extension home therefore needs many round trips before the accessibility snapshot starts. These costs share the observation budget. The historical proof below records a first Send observation that times out without testIds. This PR changes the browser collection boundary used by observations and `PlaywrightPlatformDriver.getTestIds`. ## Solution Keep browser orchestration in the daemon. Move DOM inspection into self-contained functions that execute in the main frame's isolated world. ```mermaid flowchart TD A[Browser observation] --> B{Read-only or discovery tool?} B -->|no| C[Wait for finite, in-viewport animations] B -->|yes| D[Collect testIds in cached isolated world] C --> D D -->|success| E[Map items to existing output shape] D -->|failure or timeout| F[Collect testIds through locator fallback] E --> G[Collect accessibility tree] F --> G G --> H[Return observation] classDef new fill:#fff3cd,stroke:#d39e00 class C,D,E,F new ``` Yellow boxes are new. `callInIsolatedWorld` owns the CDP session and execution context. A `WeakMap` caches them per page. Steady-state calls send one `Runtime.callFunctionOn` request. Protocol or session failures clear the matching cache entry and retry once. In-page exceptions propagate without a retry. The isolated world shares the DOM but has separate globals. Page functions travel as source text, so they cannot import helpers or capture module state. Their helpers stay inside each function. The source wrapper supplies `__name` for keepNames-transformed functions; VM tests check the serialization boundary. `discovery.ts` owns the failure policy. Animation timeouts and unreadable pages end the wait. TestId call failures and timeouts use the locator fallback. The server still checks its abort signal between observation stages. ## What changes | Condition or boundary | Before | After | |---|---|---| | Running animations | Every running animation delays collection | Only finite animations with non-empty target boxes intersecting the viewport delay collection | | Animation wait budget | 3000 ms configured wait | 1000 ms in-page cap, with 250 ms daemon grace | | Hidden page | Animation-frame polling only | Poll with a 16 ms timer while hidden | | TestId collection | Three locator calls per element | One in-page call in steady state; locator fallback on failure or a 5 s call timeout | | TestId output | Items carry testId, tag, text, and visibility | Same shape, order, limit, and text-preview rules; `tag` stays `element` | | Page navigation or closed CDP session | No shared isolated-world cache | Drop the failed entry and retry once; late failures cannot replace a newer entry | Collection includes hidden testIds with `visible: false`. Visibility follows Playwright's rules, including `display: contents`, non-empty boxes, and computed `visibility`. Zero opacity and off-screen position do not hide an otherwise visible item. The collector visits document matches before open shadow-root matches. It skips empty testIds without consuming the limit. Text remains trimmed and capped at 100 characters; null text maps to `undefined` at the daemon boundary. ## Decisions for reviewers | Decision | Reason | |---|---| | Combine **P1-10** and **P1-12** | Both improvements need the same isolated-world execution boundary. | | Cache one session and world per page | Avoid creating more execution contexts on each call to a long-lived Extension page. | | Keep page functions self-contained and exclude them from coverage instrumentation | Injected counters or module helpers would break source-text execution. VM tests exercise that boundary directly. | | Keep locator fallback and fail-open settling | Observation collection remains best-effort when CDP or page inspection fails. The 5 s cap covers the in-page call, not the fallback. | | Preserve the separate accessibility snapshot | This PR targets animation settling and testIds. **P1-14** addresses the remaining snapshot cost identified in the historical proof. | ## Review guide Read the files in this order: | # | File | What to check | |---|---|---| | 1 | `src/server/create-server.ts` | `snapshotBrowser` keeps category-based settling, collection order, and abort checkpoints. | | 2 | `src/tools/utils/constants.ts` | Animation cap, daemon grace, and testId-call budget. | | 3 | `src/tools/utils/cdp.ts` | Per-page caching, stale-entry reset guard, single retry, exception handling, and timeout cleanup. | | 4 | `src/tools/utils/in-page.ts` | Animation eligibility, hidden-page polling, visibility semantics, shadow-root order, and source-text independence. | | 5 | `src/tools/utils/discovery.ts` | Output mapping, locator fallback, and animation failure policy. | | 6 | `src/tools/utils/{cdp,in-page,discovery}.test.ts`, `src/server/create-server.test.ts` | Cache lifecycle, serialized functions, fallback, timeout, and observation integration. | <details> <summary>Testing</summary> - Fresh check at remote head `50eb3962b30ee8f5e2605dfcf307417e0282f560`: `CI=true yarn vitest run src/tools/utils/cdp.test.ts src/tools/utils/in-page.test.ts src/tools/utils/discovery.test.ts src/server/create-server.test.ts src/platform/playwright-driver.test.ts src/tools/discovery-tools.test.ts --coverage.enabled=false --maxWorkers=2 --minWorkers=1` passes: 6 files, 317 tests. - `yarn lint` passes. `yarn tsc --project tsconfig.build.json --noEmit` passes for production sources. - Standalone `yarn tsc --project tsconfig.json --noEmit` fails at the remote head with 11 test diagnostics. One is the new `in-page.test.ts:728` type predicate (`TS2677`); ten come from inherited test code. - The server suite emits an expected `EACCES` log from its read-only log-directory fixture and passes. - The `Proof` and `Unit tests` sections below retain the original historical evidence. These fresh checks do not repeat the live browser benchmarks or full coverage run. </details> ## Breaking changes None. The constants are internal. Observations may now be taken while an infinite or off-screen animation is still running; that is intended. ## Consumer impact | Consumer | Change needed | Done on branch | |---|---|---| | Extension `test/e2e/playwright/llm-workflow/` | None | n/a | | Mobile `tests/llm-workflow/` | None (browser-only code paths) | n/a | ## Proof Tested with a yalc build of this branch in the Extension (e2e build, headless). I ran a fresh before/after bench back to back: before = #64 (7d0c565), after = this branch. Every bench has 5 runs, and the 1-minute host load was 6–9. After a review round I re-checked the final commit (50eb396) and got the same numbers. | Check | Before (#64) | After | |---|---|---| | `list-testids` on home, toolMs p50/p95 (44 items) | 53 / 61 | **1 / 1** | | `list-testids` JSON, home + unlock | – | same items, order, tags and visibility. Only difference: the local chain port in 3 `text` values, which also changes between two runs of the same build | | Click on home (Tokens tab), observationMs p50/p95 | 562 / 571 | 515 / 517 | | Click on Send page, observationMs p50/p95 | 21 / 32 | 13 / 13 | | Click on account list, observationMs p50/p95 | 529 / 532 | 513 / 515 | | First Send click of a session, observationMs | **8003** (timeout, no testIds) | **43** (11 testIds) | | Send → back smoke, 5 runs | 5/5, but 4/5 Send opens returned no testIds | 5/5, all with testIds (3 runs total) | The remaining ~515 ms on home is the accessibility-tree snapshot: `get-state --observe full` takes ~509 ms without any settle. The settle and testIds now add about 6 ms together. P1-14 targets that snapshot. Known gap: the locator fallback logs only through `debugWarn`, so it is visible only with `DEBUG=true`. In the recheck it never ran: every `list_testids` took ~1–2 ms; the fallback takes ~50 ms. <details> <summary>Unit tests</summary> ``` Test Files 51 passed (51) Tests 1610 passed (1610) All files | 97.31 | 94.18 | 96.35 | 97.47 yarn lint → exit 0 ``` New tests: - `in-page.test.ts`: - each function is compiled from its own source text in a bare `vm` context, so a stray import or closure fails the test; - a guard checks that `toString()` has no bundler helpers other than `__name`; - covers the settle predicate (infinite, off-viewport, no target, hidden page) and visibility (`display: contents`, `visibility`, zero box), text, limit and shadow-root order. - `cdp.test.ts`: session/world caching, retry after navigation, in-page exceptions, timeout. - `discovery.test.ts`: the single call, plus fallback to locators on failure and on timeout. </details>
…TimeoutMs cap (#71) > **Summary:** Fix the review comments on #62, #64 and #65 in one PR on top of the stack. The approved PRs stay unchanged. **Stack context:** mm CLI v2, Phase 1, **P1-43**. Base: [#69](#69), the top of the stack. ## Problem | Comment | Problem | |---|---| | [#65: off-screen animations](#65 (comment)) | The browser settle ignored animations whose target was outside the viewport or had zero size. A drawer sliding in, a bottom sheet or an accordion opening from height 0 start that way, so the observation caught them mid-move. | | [#64: late click](#64 (comment)) | `click` / `type` checked the request signal only before the target wait. When the request timed out during the wait, the agent got "timed out", but the click still ran once the target appeared. | | [#64: batchTimeoutMs](#64 (comment)) | `batchTimeoutMs` accepted up to 300 s, but the queue and the CLI stop `run_steps` after 30 s. | | [#62: refs after `none`](#62 (comment)) | `--observe none` does not refresh refs; some refs can then click a different element. Not documented. | | [#62: reads and animations](#62 (comment)) | Reads do not wait for animations, even with `--observe full`. Not documented. | ## Solution ```mermaid flowchart TD A[click / type] --> B[Wait for target] B --> C{Request timed out?} C -->|yes| D[MM_REQUEST_ABORTED, no action] C -->|no| E[Click / tap / type] E --> F[Mutating observation] F --> G[Wait for every running finite animation, max 1 s] G --> H[Read tree] classDef new fill:#fff3cd,stroke:#d39e00 class C,D,G new ``` Yellow boxes are new or changed. ## What changes | Commit | Change | |---|---| | `fix: wait for animations that start off screen or at zero size` | `animationsSettled` waits for every running, finite animation of an element. Infinite animations are still ignored; the 1 s cap is unchanged. | | `fix: do not click or type after the request timed out` | `IPlatformDriver.click` / `type` take an optional `signal`. Both drivers call `signal.throwIfAborted()` after the target wait, right before the action (mobile `type` also after the focus tap). The tools pass `context.signal` and return `MM_REQUEST_ABORTED`. | | `fix!: cap run_steps batchTimeoutMs at 30 s` | Schema range 1000–30000, like the `cdp` `timeoutMs`. | | `docs: …` | README, SKILL.md and CLI help: refs after `--observe none`, reads and animations, abort limits, `batchTimeoutMs`. | ## Decisions for reviewers | Decision | Reason | |---|---| | One new PR instead of changing #62/#64/#65 | Pushing to an approved PR dismisses its approval, and the restack would change every PR above it. | | An action that already started is not cancelled | `@metamask/device-mcp` takes no signal (`waitForElement`, `tapElement`, `typeText`), and Playwright's actionability checks are part of `locator.click`. The driver checks the signal between steps. Cancelling inside device-mcp needs a device-mcp change (follow-up). | | Cap `batchTimeoutMs` at 30 s instead of raising the request budget for batches | Matches the CLI and queue default; long flows can be split into several batches. | | Reads stay without the animation wait (docs only) | It is a less exact snapshot, not a wrong action. Can be revisited. | | The 5 s testId collection cap stays | Agreed with the reviewer: watch real usage first. | ## Breaking changes `run_steps` rejects `batchTimeoutMs` above 30000 (validation error), also when a server raised `requestTimeoutMs`. ## Review guide | # | File | What to check | |---|---|---| | 1 | `src/tools/utils/in-page.ts` | `animationsSettled` without the viewport/size filter. | | 2 | `src/platform/playwright-driver.ts`, `src/platform/mobile-platform-driver.ts`, `src/platform/types.ts` | `throwIfAborted()` placement. | | 3 | `src/tools/interaction.ts` | Signal passed; abort mapped to `MM_REQUEST_ABORTED`. | | 4 | `src/validation/schemas.ts` | `batchTimeoutMs` range and description. | | 5 | `README.md`, `SKILL.md`, `src/cli/mm.ts` | Docs. | <details> <summary>Testing</summary> `yarn test`: 51 files, 1809 tests pass. `yarn lint` passes. Each commit passes the tests on its own. **Extension** (e2e fixture wallet, headless), before = #69, after = this branch: | Check | Before | After | |---|---|---| | Drawer sliding in from off screen (400 ms): observationMs | 515–516 (did not wait) | 914–929 (waited) | | Accordion from height 0 (400 ms): observationMs | 515–516 | 916–917 | | Home click observationMs | 513–514 | 512–515 | | Send / account list click observationMs | 12–183 / 14–164 | 14–212 / 15–164 | | `run_steps` click whose target appears 1 s after the 30 s queue timeout | **clicked** | **not clicked** | **Android** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref checks pass, same as #69. **iOS** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref checks pass, same as #69. (A first attempt hit the stale idb companion after a simulator reboot, `dtuhidd connection ... invalidated`; fixed by restarting the companion. See MetaMask/device-mcp#43 and #70.) </details>
Stack context: mm CLI v2, Phase 1, P1-08 + P1-09, combined in one PR. Base: #62 (
cryptotavares/mmcli-p1-06-observe-level-no-state); followed by #65.Problem
A queue timeout rejects the caller, but the task can keep running and overwrite another request's refs. An observation timeout also leaves collection running.
Mobile observations read test IDs and the tree from separate snapshots. A click can return the old screen or
Unlock, busy.Solution
Keep cancellation and commit fencing at separate boundaries:
RequestQueuepasses anAbortSignaland aborts it before timeout rejection. Tools check supported checkpoints. Observations use a linked controller and their own deadline.RefStore(94 lines). Each started task gets a generation. Every ref write (sessionManager.setRefMap/clearRefMapfrom tools, and the observation) checks the generation and request signal, even when a tool ignores cancellation.flowchart TD A[Queued task starts] --> B[Create signal and ref generation] B --> C[Run tool through guarded context] C --> D[Optional observation checks linked abort signal] C -->|ref write| F{Request current and not aborted?} D -->|ref write| F F -->|yes| G[Commit map] F -->|no| X[Drop write. Keep current refs.] T[Queue timeout aborts request] -.-> C T -.-> D T -.-> F classDef new fill:#fff3cd,stroke:#d39e00 classDef stop fill:#f8d7da,stroke:#c82333 class B,C,D,F,G,T new class X stopYellow boxes are new. Red boxes stop the ref write.
Tools write refs through
context.sessionManager, which the daemon proxies:setRefMapandclearRefMapgo through the request handle. Other members retain the real receiver, including#privatefields.ISessionManagerstays unchanged.Optional
captureScreen()derives both mobile outputs from one snapshot; drivers without it retain separate reads. After eligible mutations, retry busy screens and allow one unchanged-screen retry, compared against the last tree the agent received (lastScreen: the delivered observation, else thedescribe_screen/ fullaccessibility_snapshottree). Launch, cleanup, and non-batch tools that set refs skip settling.What changes
observe none)list_testidsQueue timeout errors use HTTP 500 with
TOOL_EXECUTION_FAILEDandTool execution timed out after ${effectiveTimeout}ms.MM_REQUEST_ABORTEDis the internal checkpoint response. Cancellation does not undo an action already dispatched to a driver. Refresh withdescribe-screenbefore retrying.Decisions for reviewers
lastScreenupdates only after a delivered responseReview guide
Read the files in this order:
src/server/request-queue.ts,src/types/http.tssrc/server/ref-store.tstoolSetRefs.src/server/create-server.tssetRefMap,clearRefMap), observation cancellation checkpoints,screenAfterResponse/lastScreen, batch behavior, separate diff baseline.src/tools/utils.ts,src/tools/interaction.ts,src/tools/batch.ts,src/tools/discovery-tools.tslist_testidstree-read removal.src/platform/types.ts,src/platform/mobile-platform-driver.ts,src/server/screen-settle.tsREADME.md,SKILL.mdTesting
At
6944868:yarn test52 files / 1528 tests pass; coverage 97.03 / 93.95 / 95.21 / 97.19;yarn lintpasses. Every branch above it in the stack (#65, #66, #67, #44, #68) was restacked and passesyarn test+yarn lintat its own tip.Breaking changes
None.
ToolContext.signal,IPlatformDriver.captureScreenandScreenCaptureare optional additions.RequestQueue.enqueuenow passes the signal to its callback. Existing callbacks that take no argument keep working.Consumer impact
test/e2e/playwright/llm-workflow/RequestQueue, and itssetRefMap/getRefMapimplementation is unchanged.tests/llm-workflow/Why no ref stability
The first version of this PR also renumbered refs so an element kept its number across observations (
stabilizeRefs, a session high-water mark, baseline checks, merging tool and observation refs: about 500 lines of code and 900 of tests). It was removed because the problem it solves is rare:If real runs show agents clicking stale refs, it can come back as its own ticket.
Proof
After the shrink (
6944868, run on the restacked stack tip):describe-screen(queueMs 0), then click by refdescribe-screentwice: identical refsAndroid navigation observations can still show the previous screen (the new screen mounts about 2 s later). That is the existing Android settle limit noted in #44, not changed here.
Earlier revision (with ref stability), kept for history:
Tested with yalc builds in the Extension (e2e build, headless) and Mobile (dev client on iOS sim and Android emulator). The first full run used an earlier revision (e804b95). The final commit got a targeted mobile recheck plus a smoke run on all three platforms. Details are in the proof file in the mm-cli-v2 workspace.
click --testid does-not-exist --timeout 500, thendescribe-screen, then click by refwait-for wallet-screen)click tab-bar-item-Wallet, p50 of 3)list_testidsno longer reads the tree)The Android unchanged-screen diff gap in that revision is closed by #67 and #44 (see the table above).