DEV Community

Dakota Liu
Dakota Liu

Posted on

Case Study: Freeze the Job Status Graph Before an Agent Writes the Worker

If you let a coding agent invent job statuses, you will ship retry loops, duplicate exports, and dashboards that cannot explain a row. Freeze the status graph, the legal transitions, and the terminal set in a fixture before any worker code is generated. This case study walks through one small report-export project from that fixture to a failing CI gate. You can copy the full artifact into another repo even if you never touch an agent product.

Background

You are building a tiny async exporter that turns a saved query into a downloadable CSV file. Users click export, a job row appears in the list, and a worker later writes the object. The product surface only needs five statuses, namely queued, running, succeeded, failed, and canceled for operators. That constraint looks obvious until a coding agent starts writing the worker module from a loose prompt.

Coding agents like extra branches because those extra branches look like robustness during a long coding session. They add retrying, partial, uploading, and cleanup without asking whether your API can render those words. You then spend a week mapping invented states back onto a real machine that support already agreed on. Finance also expected one failed state, not a family of almost-failed labels that never reach the invoice.

Goal

The goal is not a clever worker. The goal is a worker that cannot name a status the fixture does not list. You also want every transition checked against an allowlist of edges, including the cancel path from users. Terminal states must stay terminal, so a succeeded job cannot move back to running after a late retry.

You will keep the graph in version control, fail generation when the patch introduces a new string, and only then allow file I/O. The walkthrough uses Node's built-in test runner so you can reproduce the gate without extra frameworks. No production traffic and no invented latency numbers appear below; failing outputs are labeled examples only.

The frozen status graph

Put the contract in contracts/job-status-graph.json so humans and agents read exactly the same lifecycle file. Keep comments out of the JSON document and put the rationale inside the pull request instead.

{
  "job_type": "report_export",
  "states": ["queued", "running", "succeeded", "failed", "canceled"],
  "initial": "queued",
  "terminal": ["succeeded", "failed", "canceled"],
  "forbidden_aliases": [
    "retrying",
    "partial",
    "uploading",
    "cleanup",
    "pending",
    "done",
    "error"
  ],
  "transitions": [
    { "from": "queued", "to": "running", "actor": "worker" },
    { "from": "queued", "to": "canceled", "actor": "user" },
    { "from": "running", "to": "succeeded", "actor": "worker" },
    { "from": "running", "to": "failed", "actor": "worker" },
    { "from": "running", "to": "canceled", "actor": "user" }
  ],
  "side_effects": {
    "succeeded": ["write_csv_object", "emit_done_webhook"],
    "failed": ["emit_failed_webhook"],
    "canceled": ["delete_temp_object"]
  }
}
Enter fullscreen mode Exit fullscreen mode

That file is the entire product conversation about lifecycle for this exporter. If someone wants retrying, they edit the fixture in a separate change, not inside generated worker code. Side effects live on terminal states so the agent cannot attach a webhook to running during a claim.

Contract tests you run first

Create test/job-status-graph.test.mjs and load both the fixture and the worker module the agent is supposed to produce. The worker must export STATES, canTransition, and applyTransition for the suite. You fail the run when those exports drift from the JSON file.

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import {
  applyTransition,
  canTransition,
  STATES,
} from "../src/job-status.js";

const graph = JSON.parse(
  readFileSync(new URL("../contracts/job-status-graph.json", import.meta.url), "utf8")
);

const src = readFileSync(
  new URL("../src/job-status.js", import.meta.url),
  "utf8"
);

test("worker states equal the frozen graph", () => {
  assert.deepEqual([...STATES].sort(), [...graph.states].sort());
});

test("every declared edge is accepted", () => {
  for (const edge of graph.transitions) {
    assert.equal(canTransition(edge.from, edge.to), true, `${edge.from}->${edge.to}`);
  }
});

test("undeclared edges are rejected", () => {
  const allowed = new Set(graph.transitions.map((e) => `${e.from}->${e.to}`));
  for (const from of graph.states) {
    for (const to of graph.states) {
      if (from === to) continue;
      const key = `${from}->${to}`;
      if (!allowed.has(key)) {
        assert.equal(canTransition(from, to), false, key);
      }
    }
  }
});

test("terminal states do not leave the graph", () => {
  for (const term of graph.terminal) {
    for (const to of graph.states) {
      if (to === term) continue;
      assert.equal(canTransition(term, to), false, `${term}->${to}`);
    }
  }
});

test("applyTransition throws on illegal moves", () => {
  assert.throws(() =>
    applyTransition({ id: "job_1", status: "succeeded" }, "running")
  );
});

test("source does not mention forbidden status aliases", () => {
  for (const alias of graph.forbidden_aliases) {
    assert.equal(src.includes(`"${alias}"`) || src.includes(`'${alias}'`), false, alias);
  }
});
Enter fullscreen mode Exit fullscreen mode

Scaffold the folders, then run the suite before any generation step:

mkdir -p contracts src test
node --test test/job-status-graph.test.mjs
Enter fullscreen mode Exit fullscreen mode

Labeled example: if the agent adds "retrying" to STATES, the first test fails with a deepEqual mismatch on the two arrays. That failure is the entire point of the case. You do not negotiate with the patch; you send the agent back to the fixture.

A worker the tests will accept

Keep src/job-status.js boring on purpose so later I/O cannot hide new branches. The agent may add database calls after the graph is green, but the transition function stays a pure lookup against the fixture. The implementation below is proposed sample code that is complete enough to pass the suite.

import { readFileSync } from "node:fs";

const graph = JSON.parse(
  readFileSync(new URL("../contracts/job-status-graph.json", import.meta.url), "utf8")
);

export const STATES = Object.freeze([...graph.states]);

const ALLOWED = new Set(graph.transitions.map((e) => `${e.from}->${e.to}`));
const TERMINAL = new Set(graph.terminal);

export function canTransition(from, to) {
  if (!STATES.includes(from) || !STATES.includes(to)) return false;
  return ALLOWED.has(`${from}->${to}`);
}

export function applyTransition(job, to, actor = "worker") {
  if (TERMINAL.has(job.status)) {
    throw new Error(`terminal:${job.status}`);
  }
  if (!canTransition(job.status, to)) {
    throw new Error(`illegal:${job.status}->${to}`);
  }
  const edge = graph.transitions.find((e) => e.from === job.status && e.to === to);
  if (edge.actor !== actor) {
    throw new Error(`actor:${actor}`);
  }
  return { ...job, status: to };
}
Enter fullscreen mode Exit fullscreen mode

Notice the actor check on every legal edge. Users cancel running jobs; workers do not cancel them as a retry tactic. If the agent later writes a loop that calls applyTransition(job, "canceled", "worker"), the function throws before any object store call. That is cheaper than discovering the bug in a support ticket after duplicate webhooks.

Decision table for the exporter

Use this table in the pull request so reviewers do not re-litigate names during code review. The table is part of the artifact, not decoration, and it should match the JSON file byte for byte in meaning.

Current status Event Next status Actor Side effect
queued worker claims job running worker none
queued user clicks cancel canceled user delete temp object
running CSV written succeeded worker write object, done webhook
running query error failed worker failed webhook
running user clicks cancel canceled user delete temp object
succeeded any retry reject none
failed any retry reject none
canceled any retry reject none

If you need automatic retries, you add a new non-terminal state in the fixture first, with a numeric cap stored beside that state. Do not let the agent encode "try a few times" as an unnamed loop that still reports running. Operators cannot alert on a loop they cannot name.

Implementation sequence for the case

Work the project in this order so the agent never sees a blank worker as the source of truth.

  1. Commit contracts/job-status-graph.json with the five states and the actor-tagged edges.
  2. Commit the test file, and confirm it fails because src/job-status.js does not exist yet.
  3. Prompt the agent to implement only STATES, canTransition, and applyTransition against that fixture.
  4. Re-run node --test and reject any patch that widens STATES or mentions a forbidden alias.
  5. Only after the graph is green, allow the agent to write the CSV upload and webhook callers.

A useful prompt fragment looks like this, and you should paste the fixture rather than summarizing it in prose:

Implement src/job-status.js so test/job-status-graph.test.mjs passes.
Do not add states, aliases, or transitions that are missing from
contracts/job-status-graph.json. Do not implement object storage yet.
Enter fullscreen mode Exit fullscreen mode

You want the first coding pass to be smaller than your instinct. File I/O is where agents hide extra statuses inside retry helpers, timeout wrappers, and "just in case" cleanup functions. Keep those files out of scope until the graph is locked.

Where a shared free server fits

You can execute every command above on a laptop with Node 20 or later installed. When you want the agent and the tests in one shared workspace, MonkeyCode is one option for that loop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. It is an open-source project with free model access and a free server option, which is enough to keep the fixture, the tests, and the worker patch in a single run without standing up your own GPU box.

Do not treat that server as a production job queue or as the system of record for export rows. Use it to generate src/job-status.js, run node --test, and reject patches that widen STATES. If the free option is unavailable in your environment, the same fixture still works in GitHub Actions with the same test file.

If you already have the contract folder, try the shared runner only after the tests exist, not from a blank worker prompt.

Results of this case

Labeled example, not a production benchmark: the first agent patch introduced retrying and partially_written, and the deepEqual test failed immediately on sorted arrays. The second patch kept five states but allowed failed -> running, and the terminal test failed on that illegal edge. The third patch matched the graph, after which you could permit object-store work without renaming the lifecycle.

You did not measure tokens, wall clock time, or model quality here, because those numbers would be theater without a logged run under a fixed harness. What you did measure is binary and local: either STATES equals the fixture or CI stays red. That is the only result this method claims, and it is enough to stop duplicate CSV writes.

A second labeled failure is worth keeping in the pull request. The agent added applyTransition(job, "canceled", "worker") inside an error path that was trying to be polite. The actor check threw actor:worker, which taught the reviewer more than a style comment would have taught. Illegal actors are statuses by another name.

Limitations and who should skip this

This approach assumes the lifecycle is small and already agreed among API, UI, and support. A multi-step saga with compensating transactions needs a richer model than five strings and a handful of edges. If your jobs are truly event sourced, freeze the event catalog instead of a status graph, or you will fight the fixture on every replay.

Skip this method when the worker is a one-off script with no public API and no operator dashboard. Also skip it when you cannot name the actor for each edge, because the actor check will become guesswork and then get deleted. Do not use a coding server as the database for job rows, and do not pretend a fixture replaces an enum column.

The graph does not replace database constraints on the status column. You still want a SQL check or a native enum so a hand-written UPDATE cannot invent retrying at two in the morning. Contract tests catch agent patches; they do not catch a tired human in a replica shell.

Lessons

  • Name statuses in a file the agent cannot casually extend during a coding pass.
  • Test illegal edges and forbidden aliases, not only the happy path from queued to succeeded.
  • Treat terminal states as a lock on the row, not as a suggestion the worker may reopen.
  • Put side effects on terminal edges so webhooks cannot fire twice from a running claim.
  • Change the fixture in its own commit before you ask an agent for implementation code.

If you already pin clients and list pagination, freeze this job graph next on the smallest exporter you own. The tests stay boring, the dashboard keeps five labels, and the agent stops inventing a career ladder of statuses that nobody can operate.

Top comments (0)