Hmbown/CodeWhale · error · ExecError

element_stale — re-run get_app_state; uitest indexes change…

Error message

element_stale — re-run get_app_state; uitest indexes change with the UI

What it means

centerOf resolves an element target by re-dumping the uitest layout and indexing into the flattened tree; if the index no longer exists or has no bounds, the element is stale. uitest element indexes change whenever the UI changes, so cached indexes from an earlier get_app_state can become invalid.

Solutions

  1. Re-run get_app_state and use a fresh element index, exactly as the message says.
  2. Re-locate the target element each time by role/label rather than caching indexes.
  3. Wrap element actions in try/catch for this error and retry once after re-observation.
  4. Minimize delay between get_app_state and the action to reduce staleness windows.

Example fix

// before
const idx = (await backend.get_app_state()).elements.findIndex(...); // cached long ago
await backend.select_text({ target: { type: 'element', index: idx } });
// after
try {
  await backend.select_text({ target: { type: 'element', index: idx } });
} catch (e) {
  if (!String(e.message).includes('element_stale')) throw e;
  const state = await backend.get_app_state();
  const fresh = state.elements.findIndex(...);
  await backend.select_text({ target: { type: 'element', index: fresh } });
}
Defensive patterns

Strategy: retry

Validate before calling

const state = await backend.get_app_state();
if (index < 0 || index >= state.elements.length) throw new Error('index out of range — re-observe');

Type guard

const isLiveElement = async (backend, index) => (await backend.get_app_state()).elements[index] != null;

Try / catch

try { await act(target); } catch (e) { if (String(e.message).includes('element_stale')) { const s = await backend.get_app_state(); await act({ type: 'element', index: relocate(s) }); } else throw e; }

Prevention

When it happens

Trigger: Using an element index captured from an earlier get_app_state after the UI has changed (screen navigated, list reordered, dialog opened/closed), or an index out of range of the current flattened tree.

Common situations: Long-running automation holding element indexes across interactions; animations or async loading shifting the tree between observe and act; app updating its list between snapshot and select_text.

Related errors


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

Appendix: source

Thrown at crates/tui/plugins/computer-use/src/backends/harmonyos.mjs:112

    try {
      data = await exec.readFile(remote, { timeoutMs: 30_000 });
    } finally {
      await shell(["rm", "-f", remote]).catch(() => {});
    }
    return JSON.parse(data.toString("utf8"));
  }

  async function uiInput(args, opts = {}) {
    await deviceOut(["uitest", "uiInput", ...args], opts);
    return { action_sent: true, strategy: "event", backend: "uitest" };
  }

  async function centerOf(target) {
    rejectAppSelectors(target);
    const tree = await dumpLayout();
    const els = flatten(tree);
    const el = els[target.index];
    if (!el || !el.bounds) throw new ExecError("element_stale — re-run get_app_state; uitest indexes change with the UI");
    return el.bounds;
  }

  let frameSeq = 0;
  let recording = null; // {id, dir, startedAt, intervalMs, timer, display}
  let displayPixels = null;

  async function stopFrames() {
    const rec = recording;
    if (!rec) return null;
    rec.stopped = true;
    clearInterval(rec.timer);
    rec.controller.abort();
    let timer;
    try {
      await Promise.race([rec.pending, new Promise((_, reject) => {
        timer = setTimeout(() => reject(new ExecError("Harmony recording frame did not stop within 2 seconds")), 2_000);
      })]);

View on GitHub (pinned to 73e0f67d83)