Enforce a WIP limit where the count goes up, not where work starts
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
readytoin_progressdoesn’t. It’s the same item changing lanes. - Moving
in_progresstoin_reviewdoesn’t either, under the old definition. - Adding a brand new item directly as
readydoes.
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:
- 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.
- 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.
- 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.