Enforce a WIP limit where the count goes up, not where work starts

  • automation
  • reliability
  • javascript

Work in progress limits are one of those ideas that sound obviously right. Don’t start more than you can finish. Cap the number of open items, and the team (or the script) is forced to close things out before picking up new ones. I added one to a small automation loop that runs a side project’s backlog, and for a while it did exactly what I wanted.

Then one morning the loop asked for its next item and got nothing back. The backlog was full of specified, prioritized work. Nothing was broken in the usual sense. The loop just kept saying there was nothing it was allowed to start, cycle after cycle, for five runs in a row.

The cap was working perfectly. It was checking the wrong thing at the wrong moment.

How the deadlock happened

The backlog is a JSON file with items that move through statuses: proposed, ready, in_progress, in_review, and then a few terminal ones. A small CLI reads it and answers one question for the loop: what can I work on next?

The capacity check looked roughly like this:

const QUEUED = ['ready', 'in_progress', 'in_review'];

function capacity(b) {
	const open = b.items.filter((i) => QUEUED.includes(i.status));
	return {
		total_open: open.length,
		can_start: {
			revenue: inFlight(b, 'revenue') < LIMITS.revenue_in_flight && open.length < LIMITS.total_open,
			infra: open.length < LIMITS.total_open,
		},
	};
}

The total limit was five. At the time there were four items in review, each one a pull request waiting for me to merge it, plus one item marked ready. That’s five, so every can_start came back false.

Look at what starting an item actually does to that count, though. Starting moves an item from ready to in_progress. Both statuses are in the open list, so the number doesn’t change. The gate was refusing an action that could never push the count over the limit. The only thing it could do was strand work that had already been written up and was ready to go.

And the thing that was really holding the count high, four PRs waiting on a human, was something the loop couldn’t fix by working harder. It can’t merge its own pull requests. So the system was stuck waiting on me, while also refusing to do the one thing it could usefully do.

Put the check on the transition that raises the count

The fix came from asking a simple question about every status change: does this make the number bigger?

  • Moving ready to in_progress doesn’t. It’s the same item changing lanes.
  • Moving in_progress to in_review doesn’t either, under the old definition.
  • Adding a brand new item directly as ready does.

So the cap belongs on add, not on start. Starting work only checks the per type limits that are actually about focus, like “only one revenue experiment in flight at a time.” Adding new open work is where the total is enforced:

// Only the statuses the worker can actually influence.
const OPEN_WIP = ['ready', 'in_progress'];

function capacity(b) {
	const open = b.items.filter((i) => OPEN_WIP.includes(i.status));
	return {
		total_open: open.length,
		// Reported, but deliberately not capped: this is the human's merge queue.
		in_review: b.items.filter((i) => i.status === 'in_review').length,
		can_start: {
			revenue: inFlight(b, 'revenue') < LIMITS.revenue_in_flight,
			infra: true,
		},
	};
}

function add(b, item) {
	if (OPEN_WIP.includes(item.status) && capacity(b).total_open >= LIMITS.total_open) {
		throw new Error(`total_open is at its limit; add "${item.title}" as proposed instead`);
	}
	b.items.push(item);
}

Two details matter here. First, proposed items never count and are never refused. Whoever files ideas into the backlog can keep doing that; the limit only applies to work that arrives already open. Second, the error message tells the caller what to do instead. A refusal that just says “no” invites someone to bypass the check. A refusal that says “file it as proposed” keeps the idea and keeps the limit.

Don’t cap what you can’t drain

The other half of the fix was taking in_review out of the capped count. That one felt wrong at first, because review really is work in progress. But a limit is a control, and a control should only measure things the controlled party can change.

The loop can finish a ready item. It can’t approve a pull request. Counting the review queue against its limit turned a slow reviewer (me, on a busy week) into a full work stoppage, which punishes the wrong part of the system. So in_review is still counted and printed in the status line, where I’ll see it and feel a little guilty, but it doesn’t gate anything.

The per type “in flight” limits still include review, on purpose. Those exist so that only one revenue change is live and being measured at a time. A change sitting in review is about to affect the measurement, so it belongs there. Same status, different question, different answer. Writing that reasoning down in a comment next to each list turned out to be the most useful part of the change, because the original bug came from one list being reused for two different questions.

A quick test for any limit

When I add a cap to anything now, whether it’s a backlog, a job queue, or a rate limiter, I run through three checks:

  1. Which transitions increase the count? The check goes on those, and only those. A gate on a transition that can’t raise the number can only ever block progress.
  2. Can the thing being limited actually reduce the count? If not, measure it and show it, but don’t let it hold the limit closed.
  3. What happens when the limit is hit? There should be a clear alternative path, like parking new work as proposed, and the message should name it.

It’s also worth writing a test for the boring stuck state directly: a backlog full of review items plus one ready item. If next returns nothing on that fixture, you have a deadlock, and it’s much nicer to find it in a test than five cycles into a quiet week.

The tradeoffs

Moving the cap means the total can briefly look over the limit if review items come back as changes requested and land in in_progress again. I decided that’s fine. Those items are real work the loop should finish, and refusing them would recreate the same deadlock from another direction.

It also means the review queue can grow without bound. That’s a real risk, but it’s a human problem with a human signal. The count is right there in the status output, and the honest fix for a long review queue is reviewing, not making the automation stop.

The broader lesson I keep relearning: a limit is only as good as the place you check it. Put it where the number actually goes up, and make sure whoever it constrains has a way to bring the number back down.