Most AI coding assistants are demoed on a desk: laptop plugged in, stable Wi-Fi, full attention. But the moment I approve or monitor a long-running AI task from a phone, the environment becomes hostile — the app gets backgrounded, the device hops from Wi-Fi to LTE, iOS starts throttling background execution, and the battery drops below 20%.
This article is a reproducible benchmark method for one question: does an AI coding task survive the mobile lifecycle, and can you tell the difference between 'recovered', 'restarted', and 'silently dropped'?
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Any agent backend with a task ID you can poll works for this method; the benchmark is about the lifecycle, not the vendor.
For each run, classify the final state:
| Outcome | Definition |
|---|---|
| Recovered | |
| Same task ID, output continues from where the connection dropped | |
| Restarted | |
| New task ID or duplicate execution; original work discarded or redone | |
| Silent loss | |
| UI shows 'running' or a spinner, but the backend has no live task |
Silent loss is the dangerous one. It looks like progress.
The client just needs to launch a task, poll by ID, and log transitions with timestamps. Pseudocode you can adapt to React Native, Flutter, or a plain fetch loop:
// lifecycle-bench.mjs — run in your app's debug console or a Node harness
const log = [];
const stamp = (event, extra = {}) =>
log.push({ t: Date.now(), event, ...extra });
async function launchTask(prompt) {
const res = await fetch(`${BACKEND}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const { taskId } = await res.json();
stamp("launched", { taskId });
return taskId;
}
async function poll(taskId) {
let lastStatus = null;
for (;;) {
try {
const res = await fetch(`${BACKEND}/tasks/${taskId}`);
const task = await res.json();
if (task.status !== lastStatus) {
stamp("status", { status: task.status, progress: task.progress });
lastStatus = task.status;
}
if (["done", "failed"].includes(task.status)) return task;
} catch (e) {
stamp("poll_error", { error: String(e) });
}
await new Promise((r) => setTimeout(r, 2000));
}
}
// On app foregrounding, run: poll(savedTaskId)
// Then diff the log against the backend's task history.
After each run, compare the client log with server-side task history. If the client saw poll_error
for 4 minutes and the server shows the task still executing, that's a recovered run. If the server shows the task was killed at the disconnect, your UI lied — classify accordingly.
Run the same prompt (a medium-length task, e.g. 'refactor this module and run the tests') under each condition. One variable per run:
Expected observations from my runs: conditions 2–4 are survivable when the client persists the task ID and the backend treats tasks as resumable by ID. Condition 5 is where I've seen silent loss most often — the backend's idle timeout fires while iOS defers the client's keepalive. Condition 6 fails entirely if the task ID only lives in memory.
taskId
(Keychain/Keystore, not just AsyncStorage if it's sensitive) and re-attach on launch before offering a 'start new task' button.lastSeenAt
. If server lastSeenAt
is stale but UI shows 'running', surface 'connection lost — task status unknown' instead of a spinner.If your tasks are sub-second, this whole matrix is overkill — just retry. And if the task handles sensitive code or credentials on a device you don't control, a free hosted server is the wrong target entirely; test continuity against infrastructure you're authorized to use.
If you run this matrix on your own setup, I'd like to compare notes: device, OS version, which transition you triggered, and whether the task recovered, restarted, or silently disappeared. That last one is the number nobody's dashboard shows.