# Give your coding agent a state machine

> Build a small JavaScript state machine for a coding agent, with explicit transitions, a retry limit, a useful event log, and tests.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-21 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/agent-state-machines/

A coding agent can get stuck fixing its own fixes. It edits a file, runs the tests, gets a failure, and tries another patch. An hour later, it is still trying.

You can tell the model to stop after two attempts. You can also put that limit in the program that runs it.

Let's build the second option in JavaScript. We'll describe the stages of a small coding task, decide which events can move it forward, and stop it when its retry allowance runs out.

This example simulates the events. It doesn't call a model or edit your project. We're building the controller you would put around those operations.

## Give each stage a name

A **state machine** records where a process is and which events can change that state. I covered the underlying idea in [Finite State Machines](https://flaviocopes.com/finite-state-machines/).

For our coding loop, the states are `planning`, `editing`, `testing`, `recovery`, `done`, and `stopped`.

Planning produces a bounded task. Editing produces a patch. Testing produces an observed result. Recovery decides whether another attempt is available.

The model can propose a patch, but it cannot declare that the tests passed. The code running the test command produces that event from its result.

This distinction is useful even if your agent only changes local files. A fluent explanation of why the patch works should never move the run into `done`.

## Write the transition function

Create a folder called `agent-loop`. Save this complete file as `machine.mjs`:

```javascript
export function transition(run, event) {
  const next = {
    planning: { PLAN_READY: 'editing', ERROR: 'stopped' },
    editing: { PATCH_READY: 'testing', ERROR: 'recovery' },
    testing: { PASS: 'done', FAIL: 'recovery', ERROR: 'recovery' },
    recovery: { RETRY: 'editing', STOP: 'stopped' },
    done: {},
    stopped: {}
  }[run.state]?.[event]

  if (!next) {
    throw new Error(`Cannot handle ${event} in ${run.state}`)
  }

  const state = next === 'editing' && run.attempts >= 2
    ? 'stopped'
    : next

  return {
    state,
    attempts: run.attempts + Number(state === 'editing')
  }
}

export function createRun() {
  let current = { state: 'planning', attempts: 0 }
  const events = []

  return {
    send(event) {
      const next = transition(current, event)
      events.push({ event, from: current.state, ...next })
      current = next
      return { ...current }
    },
    get log() {
      return structuredClone(events)
    }
  }
}
```

The table lists every allowed transition. Sending `PASS` during `editing` throws an error, because that state has no `PASS` entry.

The attempt count increases when we enter `editing`. After the second editing attempt, a request to retry moves into `stopped` instead. Planning doesn't consume an attempt.

`ERROR` covers a failed operation, such as an unavailable model endpoint or a test command that couldn't start. Our small example stops on a planning error and sends editing or testing errors through recovery. A real application can distinguish those failures when they need different responses.

## Follow one failed attempt

Save this as `run.mjs` beside the first file. We will simulate a failed test followed by a successful repair:

```javascript
import { createRun } from './machine.mjs'

const run = createRun()

for (const event of [
  'PLAN_READY',
  'PATCH_READY',
  'FAIL',
  'RETRY',
  'PATCH_READY',
  'PASS'
]) {
  console.log(event, run.send(event))
}

console.log(JSON.stringify(run.log, null, 2))
```

Run it with a recent Node.js:

```bash
node run.mjs
```

The final state is `done`, with two attempts. The log records how the run reached it, including the failed test and the retry. An invalid event leaves both the state and the log untouched, which lets the caller report the mistake without corrupting the run.

In an actual runner, entering `editing` would call your patch operation. Entering `testing` would run a fixed test command, capture its output, and send `PASS`, `FAIL`, or `ERROR`. Keep those operations outside `transition()`, so you can test the rules without paying for model calls.

## Check the paths that should stop

A successful run tells us little about the limit. Save these tests as `machine.test.mjs`:

```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRun } from './machine.mjs'

test('rejects success before testing', () => {
  const run = createRun()
  run.send('PLAN_READY')
  assert.throws(() => run.send('PASS'), /Cannot handle/)
  assert.equal(run.log.length, 1)
})

test('stops after two failed attempts', () => {
  const run = createRun()
  for (const event of [
    'PLAN_READY', 'PATCH_READY', 'FAIL', 'RETRY',
    'PATCH_READY', 'FAIL', 'RETRY'
  ]) run.send(event)

  assert.equal(run.log.at(-1).state, 'stopped')
  assert.equal(run.log.at(-1).attempts, 2)
  assert.throws(() => run.send('RETRY'), /Cannot handle/)
})
```

Run them with `node --test`. They verify that a run cannot skip testing or keep retrying forever.

## Recovering after an interruption

This log lives in memory. It disappears when the process exits.

For durable runs, save events with a run ID and keep the patch, test output, and repository revision they refer to. On restart, inspect the working directory before resuming. Replaying a log doesn't undo a partially written file or prove that the current code matches the recorded test result.

Be especially careful if the process died during testing. Record the interrupted operation, rerun the checks against the current files, then decide the next event. A timeout also needs an explicit outcome. The state machine cannot stop a hanging command unless the surrounding runner enforces that timeout.

This example limits editing attempts. You would add a separate wall-clock or spending budget around model calls if those are the resources you need to control.

For actions that publish, delete data, or spend money, there's another boundary to build. I covered that in [handling irreversible agent actions](https://flaviocopes.com/ai-agent-irreversible-actions-safely/). Start here with the smaller problem: a coding task whose tests fail twice should hand the remaining work back to you.
