# The hardest part of a long-running agent job is knowing where it got to

> Source: <https://dev.to/minh_thanhdang_1cb47092e/the-hardest-part-of-a-long-running-agent-job-is-knowing-where-it-got-to-50ac>
> Published: 2026-08-31 20:54:44+00:00

*I wrote this post for my entry to the All Things Agentic Hackathon.*

TLDR: I built a five-agent design team on Gemini (Including Gemini Flash 3.7 and Gemma 4) that takes a brief and a folder of photographs and returns finished, editable pages. The interesting engineering was not the prompts. It was deciding wh

ere the run's progress lives. Code: [github.com/minhthanhdang/vibes-ai](https://github.com/minhthanhdang/vibes-ai).

Vibes AI is a design co-pilot. Upload photographs, describe what the thing is for, and it designs the pages: real crops, generated backgrounds, type in any Google Fonts family, all written as geometry that can be dragged afterwards.

There are five agents. An orchestrator holds the other four as tools, so every hop is request and response, and the user reads one reply instead of a transcript of agents talking to each other. A property analyzer reads each upload in six design dimensions. An image editor cuts. An image generator draws the picture the gallery does not have. A design assistant does the actual designing.

The part I want to write about is the unattended run. One form (purpose, page count, palette, vibe, size) and then no further human input until the pages are done.

Designing six pages is minutes of model calls, not milliseconds. My first instinct was one request that loops over the pages and returns when it is finished.

That shape gives nothing back. No honest progress, no Stop button that means anything, and a failure at page four throws away pages one to three.

So a page became the unit of work. One job designs one page. The job is a row in an `AgentRun`

table, a worker claims it under a lease, and when it settles it enqueues the next page inside the same transaction that marks the current one done:

``` js
const chained = await db.$transaction(async (tx) => {
  const won = await tx.agentRun.updateMany({
    where: { id: run.id, status: RunStatus.RUNNING, startedAt: run.claimedAt },
    data: { status: RunStatus.SUCCEEDED, output, error: null, finishedAt: now() },
  });
  if (won.count !== 1 || !next) return false;
  await enqueueVibesPage(tx, { projectId: run.projectId, boardId: job.boardId, ... });
  return true;
});
```

The claim is a compare-and-set on the row's status and start time, so two workers racing the same job cannot both win. Cloud Scheduler ticks the worker. The chain runs itself.

Everything good about the run falls out of that one decision. A failure at page four keeps pages one to three, because nothing rolls back. Stop ends the run at a page boundary. A closed tab does not kill anything, because the browser was never driving it.

Here is the part I got wrong first.

A run needs to know where it got to. The obvious answer is a progress record: a row that says four of six pages are done, updated after each page. I wrote that. It went stale the first time I deleted a page by hand.

The record was a second account of a fact the artifact already held. Two accounts of one fact drift, and the record is always the one that is wrong.

The fix was to stop keeping it. A page is designed if there is anything on it that is not its own background:

``` js
export function vibesPageDesigned({ elements, pageId }) {
  const pages = pagesInReadingOrder(boardPages(elements));
  const page = pages.find((candidate) => candidate.id === pageId);
  return page ? !pageIsBlank(elements, pages, page) : false;
}
```

That is the whole of it. Asking "is anything on this page?" is the same question as "was this page designed?", so the answer cannot go stale. Resume, Stop and partial failure all became free. Reopening a half-finished board offers to design the four pages that are still blank, and it is right about which four without storing anything.

The generalisation I would keep: when a long job needs to know where it got to, ask the artifact, not a record of the work.

The design agent is the one that needed the most care. It runs a tool loop, and every round the page it is building is rasterised on the server, written to Cloud Storage, and handed back as a `gs://`

file part that Gemini fetches itself. No image bytes go through the context window.

The picture ships with the same page in words: every block boxed, stacking order, overflow marks, contrast of text against what sits behind it. Both are produced from one read of the scene, so the picture and the description cannot disagree. If the render fails, the answer says so, instead of letting the model narrate a page it was never shown.

A design gets twelve rounds and eight pictures. Budgets like that are usually enforced silently, which produces an agent that behaves strangely for reasons it cannot explain. I tell it instead. When the picture budget is spent, the next result carries a line saying so, and asks it to work from what it has already seen and to say plainly in its closing line if it placed something it could not look at. An agent that knows it is blind places conservatively. That was a bigger quality change than any prompt edit.

The image editor gets an intention in words and returns a rectangle: `[ymin, xmin, ymax, xmax]`

, normalized against the image it was shown. Code does the rest. It validates that the minimums sit below the maximums, that the box is inside the frame, and that the aspect is within tolerance. On failure the validation error is appended to the prompt and it tries again. Three attempts, then it reports failure rather than inventing a box.

Cutting is arithmetic, and every cut is filed as a version linked to its original, never an overwrite.

201 test files, 3553 assertions, four seconds, no cloud credentials and no database.

That is only possible because of one split: every agent is a folder that owns exactly one model function, and the executor half (bucket writes, database rows, queue jobs) lives outside it. The loop around a model can then be exercised with a fake executor, which is where all the interesting behaviour is anyway.

Two of the tests walk the source tree rather than call anything. One fails the build if any reasoning agent is wired to a model below the floor the hackathon requires. The other fails if a model call escapes onto the raw REST transport instead of going through the SDK. Claims like that belong in a red build, not in a README paragraph.

The detection format is y-first: `[ymin, xmin, ymax, xmax]`

, normalized 0 to 1000. Asking for x, y, width and height fights the training and the boxes get measurably worse. Convert to pixels in code instead.

An asked-for aspect ratio should land on the nearest native canvas by proportion, not by numeric difference. Sort the wrong way and a portrait request comes back landscape.

Burst throttling on the model endpoint surfaces as a 404, not a 429. I spent an evening on that one.

Cloud Run for the app, Cloud SQL for PostgreSQL 18 over the Node connector (no IP allowlist), Cloud Storage for originals, crops and renders with signed URLs both ways, Cloud Scheduler driving the two queue workers, Secret Manager for the credentials.

`gemini-3.7-flash`

does the reasoning and the seeing. `gemini-3-pro-image`

draws. The analyzer runs on `gemma-4-26b-a4b-it-maas`

, open-weight, served on the same Vertex endpoint through the same client. Everything goes through `@google/genai`

in Vertex mode.

The whole thing is MIT, and the repo is at [github.com/minhthanhdang/vibes-ai](https://github.com/minhthanhdang/vibes-ai).
