Hmbown/CodeWhale · error · ServerError

element_stale

element_stale

Error message

element ${target.index} of ${where} no longer resolves (${res?.reason ?? "not_found"}) — the user or the app may have changed it; call get_app_state again

What it means

Before acting, normalizeTarget re-resolves the element by its accessibility path (via the injected resolve callback) and throws code "element_stale" when the element no longer exists (res.found false or no element). The UI changed between get_app_state and the action, so the flat index is no longer trustworthy; the message includes the resolve failure reason and tells the caller to re-observe.

Solutions

  1. Call get_app_state again and re-locate the element (by role/label) in the fresh observation, then retry the action once.
  2. Minimize the delay between observation and action; don't cache element targets across long waits.
  3. If the element legitimately disappeared, treat it as success-or-no-op and update the automation plan rather than retrying blindly.
  4. Handle the res.reason when available (e.g. window closed vs not_found) to choose between re-observing and aborting.

Example fix

// before
await act({ type: "element", state_id: st.state_id, index: 5 }); // long delay, dialog gone

// after
try {
  await act({ type: "element", state_id: st.state_id, index: 5 });
} catch (e) {
  if (e.code === "element_stale") {
    const fresh = await getAppState(computerId);
    const i = fresh.elements.findIndex(el => el.role === st.elements[5].role && el.label === st.elements[5].label);
    if (i >= 0) await act({ type: "element", state_id: fresh.state_id, index: i });
  } else throw e;
}
Defensive patterns

Strategy: try-catch

Try / catch

try {
  return await act({ type: "element", state_id, index });
} catch (e) {
  if (e.code === "element_stale") {
    const fresh = await getAppState(computerId);
    const el = fresh.elements.find(x => x.label === observed.label && x.role === observed.role);
    if (!el) throw new Error("element gone after re-observe; aborting");
    return await act({ type: "element", state_id: fresh.state_id, index: fresh.elements.indexOf(el) });
  }
  throw e;
}

Prevention

When it happens

Trigger: Element target whose recorded path cannot be resolved in the live app: the element was closed/removed, the window changed (element.windowIndex no longer valid), the app navigated to another screen, or the app restarted between observation and action.

Common situations: An app shows a transient dialog that disappears before the click; a page finishes loading and re-renders the tree; the user interacts with the machine concurrently; automation retries an action long after the observation expired.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/f1cfd8ee2a5999a8. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/mcp/server.mjs:216

    }
    if (target.x < 0 || target.y < 0) throw new ServerError("bad_target", "raster coordinates must be non-negative");
    const pt = rasterToPoints(computer.id, target.x, target.y);
    return { x: Math.round(pt.x), y: Math.round(pt.y), strategy: "event", coordinate_space: "raster" };
  }
  if (target?.type === "element") {
    const { state, element, stateId } = resolveElement(target, computer);
    if (state.computerId && state.computerId !== computer.id) {
      throw new ServerError("state_wrong_computer", `state_id "${stateId}" belongs to computer "${state.computerId}", not "${computer.id}" — call get_app_state on that computer again`);
    }
    // The receipt must name the observation actually resolved — a bare index
    // binds the computer's latest state, so reporting `target.state_id` would
    // say "undefined" for the common case.
    const where = `state ${stateId} (${state.app_ref?.name ?? state.app_ref?.bundle_id ?? `pid ${state.app_ref?.pid}`})`;
    let fresh = null;
    if (resolve) {
      const res = await resolve({ app_ref: state.app_ref, windowIndex: element.windowIndex ?? 0, path: element.path });
      if (!res?.found || !res.element) {
        throw new ServerError("element_stale", `element ${target.index} of ${where} no longer resolves (${res?.reason ?? "not_found"}) — the user or the app may have changed it; call get_app_state again`);
      }
      fresh = res.element;
      if (fresh.role !== element.role) {
        throw new ServerError("element_stale", `element ${target.index} of ${where} changed role (${element.role} → ${fresh.role}) — call get_app_state again`);
      }
      // In-place replacement: same role and geometry but a different label is
      // still a different element (e.g. "Load" → "Confirm").
      if (fresh.label !== element.label) {
        throw new ServerError("element_stale", `element ${target.index} of ${where} changed label (${element.label} → ${fresh.label}) — call get_app_state again`);
      }
    }
    if (kind === "semantic") {
      return {
        app_ref: state.app_ref, windowIndex: element.windowIndex ?? 0, path: element.path,
        strategy: "a11y", role: element.role, label: element.label, reacquired: false,
        ...(element.runtime_id ? { runtime_id: element.runtime_id, window_runtime_id: element.window_runtime_id } : {}),
      };
    }

View on GitHub (pinned to 73e0f67d83)