Kanban Board: WIP Limits, Flow Metrics and Expedite Lanes
MediumAI-assisted coding · AI-Assisted Debugging
Kanban Board: WIP Limits, Flow Metrics and Expedite Lanes
You've joined the platform team that owns Flowboard, the small service behind the engineering org's Kanban board. Last sprint's retro had two complaints: the board lets teams exceed their WIP limits, and the cycle-time numbers in the weekly report "don't match what we remember". Two features are queued behind the fixes. Every time is an integer number of minutes since the board's epoch (minute 0); week 0 is minutes 0 ... 10079, week 1 is 10080 ... 20159, and so on.
Codebase
| File | What it does |
|---|---|
flow/api.py | run(operations) entry point and the Dispatcher that routes each operation |
flow/service.py | FlowService: create, move and finish cards, plus the board view |
flow/rules.py | Movement rules (legal step, finished half, WIP room) and argument checks |
flow/board.py | Board: storage of cards and where they sit; no business rules |
flow/metrics.py | MetricsService: cycle time, lead time, throughput |
flow/blocking.py | BlockTracker stub for Part 2 (routes exist, answer "not supported") |
flow/bulk.py | flow operation: replays a synthetic stream of cards through FlowService |
flow/models.py | The Card dataclass |
flow/errors.py | FlowError and every error code |
flow/config.py | Columns, WIP limits, lanes, time constants |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh board and returns one result per operation. Every operation is [name, ts, ...args]; ts is an integer number of minutes and never decreases from one operation to the next. Errors are returned as "error: <code>" strings, never raised. An unknown operation name returns "error: unknown operation"; a wrong number of arguments or a non-integer ts returns "error: invalid arguments".
The board. Columns, left to right: backlog, ready, in_progress, review, done. in_progress and review are *split* columns: a card sitting there is either in the doing half (just arrived) or the finished half (work done, waiting to be pulled on). WIP limits are ready 3, in_progress 4, review 2; backlog and done are unlimited.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
create | card_id, title | true | invalid arguments, card exists |
move | card_id, column | true | invalid arguments, card not found, unknown column, card done, invalid move, not finished, wip limit reached: <column> <load>/<limit> |
finish | card_id | true | invalid arguments, card not found, card done, column not split, already finished |
board | none | {column: [labels]} for all five columns | none |
cycle_time | card_id | minutes | invalid arguments, card not found, not done |
lead_time | card_id | minutes | invalid arguments, card not found, not done |
throughput | week | number of cards | invalid arguments |
flow | prefix, count, spacing | cards that reached Done | invalid arguments |
- A new card starts in
backlogatts.card_idandtitlemust be non-empty strings; any argument of the wrong type returnsinvalid argumentsbefore anything else is checked. - A forward
movegoes exactly one column to the right (invalid moveotherwise, including moving to the column the card is already in). A backwardmovemay jump to any earlier column. A card that arrives in a column, forwards or backwards, starts in its doing half and goes to the back of that column's line. Cards indonecannot move (card done). finishflips a card inin_progressorreviewto the finished half. A card can leave a split column forward only from its finished half (not finished); moving backward is always allowed.- WIP limit: a move into a limited column is refused when the cards already in that column (both halves) number at least the limit. The code is exactly e.g.
wip limit reached: in_progress 4/4. A refused operation changes nothing. boardlists each column's cards in the order they arrived there. A card in the finished half is shown asid*.- Cycle time = minute the card entered
doneminus the minute it first ever enteredin_progress. Moving a card backward (even all the way toreadyorbacklog) never resets that start. Lead time = minute it entereddoneminus its creation minute. - Throughput of week
w= number of cards whose entry intodonefalls in[w * 10080, (w + 1) * 10080);weekmust be an integer>= 0. flowis the capacity team's load generator; its behaviour is documented inflow/bulk.py. Callers keep latertsvalues at or after the last minuteflowused.
Worked example
[["create", 0, "A", "login page"],
["move", 5, "A", "ready"],
["move", 10, "A", "in_progress"],
["finish", 20, "A"],
["move", 30, "A", "review"],
["move", 40, "A", "in_progress"],
["finish", 45, "A"],
["move", 50, "A", "review"],
["finish", 55, "A"],
["move", 60, "A", "done"],
["cycle_time", 61, "A"],
["lead_time", 61, "A"]]returns [true, true, true, true, true, true, true, true, true, true, 50, 60] — the review bounce at minute 40 does not restart the clock that began at minute 10.
Part 1 — Fix the WIP and metrics bugs (FLOW-377, FLOW-381, FLOW-390)
Goal: make the board enforce the rules above; make the reported numbers match them.
Reports from the teams:
- "Our
in_progresscolumn had five cards on it. Two of them were sitting in the finished half waiting for review, and the board let a fifth one in." - "A story I bounced from Review back to In Progress shows a cycle time of 12 minutes, but it took most of the afternoon." (Another story went back to Ready and later returned; check that too.)
- "A card finished at exactly midnight on Sunday is in the wrong week of the throughput report."
A card's *work* happening in a half is not what WIP measures; the column's slots are. Fix the code so the rules in the section above hold, including the error order in the table. The existing tests in tests/ show the current behaviour; at least one of them fails.
Example (limit of 4 in in_progress; A is finished but still holds a slot)
[["create", 1, "A", "a"], ["create", 1, "B", "b"], ["create", 1, "C", "c"], ["create", 1, "D", "d"], ["create", 1, "E", "e"],
["move", 2, "A", "ready"], ["move", 2, "A", "in_progress"],
["move", 3, "B", "ready"], ["move", 3, "B", "in_progress"],
["move", 4, "C", "ready"], ["move", 4, "C", "in_progress"],
["move", 5, "D", "ready"], ["move", 5, "D", "in_progress"],
["finish", 6, "A"], ["move", 7, "E", "ready"], ["move", 7, "E", "in_progress"]]The last result is "error: wip limit reached: in_progress 4/4".
Part 2 — Blocked cards (FLOW-412)
Goal: teams want to flag cards stuck on something external, keep them from silently moving, and report how long they were stuck.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
block | card_id, blocker_id | true | invalid arguments, card not found, card done, blocker exists |
unblock | card_id, blocker_id | true | invalid arguments, card not found, blocker not found |
blocked_time | card_id | minutes | invalid arguments, card not found |
Rules
- A card can have several blockers at once (each a non-empty string id, unique among that card's *active* blockers; an id can be reused after it was unblocked). A card is blocked while at least one blocker is active.
- A blocked card cannot
move(any direction) and cannotfinish: both returncard blocked. Inmovethat check comes right afterinvalid move, beforenot finishedand the WIP check. Infinishit comes last, afteralready finished. - Blocked cards still occupy their WIP slot, and blocking does not change cycle or lead time.
blocked_timeis the number of minutes during which the card had at least one active blocker, as of the operation'sts. A blocker that is still active counts up tots. Minutes covered by several blockers at once are counted once. Time spent before a card is blocked or after it is unblocked does not count.boardlabels get a!after the card id (after*if both apply):A*!.
Example
[["create", 0, "A", "a"],
["block", 10, "A", "vendor"],
["block", 15, "A", "env"],
["unblock", 20, "A", "vendor"],
["move", 21, "A", "ready"],
["unblock", 30, "A", "env"],
["blocked_time", 40, "A"]]returns [true, true, true, true, "error: card blocked", true, 20] — minutes 10 to 30 are blocked once, not 25.
Part 3 — Expedite lane and percentile reports (FLOW-430)
Goal: the release manager wants an Expedite swimlane for urgent work, per-lane metrics, and a percentile cycle-time report. This changes how WIP limits work.
Rules
- Every card is in a lane:
standard(default) orexpedite.createtakes an optional third argument, the lane (any other value returnsinvalid lane, checked afterinvalid argumentsand beforecard exists).flowtakes the same optional lane as a fifth argument, checked after its other arguments. - WIP limits now apply to standard cards only. An expedite card is never refused by a column's WIP limit, and expedite cards are not counted towards a column's standard load.
- Instead, each limited column (
ready,in_progress,review) holds at most 1 expedite card (both halves). Moving an expedite card into a column that already has one returnsexpedite limit reached: <column> 1/1.backloganddoneare unlimited for both lanes. This check sits where the WIP check sat in themoveerror order. - New operation
expedite(card_id)promotes a card to the expedite lane where it stands. Errors in order:invalid arguments,card not found,card done,already expedite,expedite limit reached: <column> 1/1(the card's current column already has an expedite card). Promotion is one-way and frees the card's standard slot. boardshows expedite cards with a^prefix:^A*!.throughputtakes an optional second argumentlane("all"(default),"standard","expedite"). A card belongs to the lane it is in when queried. Any other value isinvalid lane(checked afterweek).report(lane)returns{"done": n, "cycle_avg": x, "lead_avg": y}over cards indonefor that lane ("all"allowed). Averages are rounded half up to an integer minute; withn = 0both arenull. Invalid lane returnsinvalid lane.percentile(lane, p)returns the nearest-rank percentile of cycle time: sort the lane's cycle times ascending, take the value at 1-based rankceil(p * n / 100).pmust be an integer from 1 to 100 (invalid arguments, checked first); theninvalid lane; with no done cardsno completed cards.- Scale: hidden tests push thousands of cards through
flowin onerun, across both lanes, and check the board repeatedly. The whole suite has a few seconds.
Example 1: lanes (ready holds 3 standard cards and 1 expedite card)
[["create", 1, "S1", "a"], ["create", 1, "S2", "b"], ["create", 1, "S3", "c"], ["create", 1, "S4", "d"],
["create", 1, "X1", "hotfix", "expedite"], ["create", 1, "X2", "hotfix 2", "expedite"],
["move", 2, "S1", "ready"], ["move", 2, "S2", "ready"], ["move", 2, "S3", "ready"],
["move", 2, "S4", "ready"], ["move", 2, "X1", "ready"], ["move", 2, "X2", "ready"]]returns [true, true, true, true, true, true, true, true, true, "error: wip limit reached: ready 3/3", true, "error: expedite limit reached: ready 1/1"].
Example 2: percentiles
[["flow", 10, "c", 4, 20], ["percentile", 200, "all", 50], ["percentile", 200, "all", 75], ["percentile", 200, "all", 1]]returns [4, 6, 11, 5]. flow creates c1 ... c4 with cycle times 11, 5, 12, 6; sorted they are 5, 6, 11, 12, so p50 is rank 2 (6), p75 is rank 3 (11) and p1 is rank 1 (5).
Notes
models.pyand the step sequence inbulk.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
Example 1
- Input:
- [["create",0,"A","login page"],["move",5,"A","ready"],["move",10,"A","in_progress"],["finish",20,"A"],["move",30,"A","review"],["move",40,"A","in_progress"],["finish",45,"A"],["move",50,"A","review"],["finish",55,"A"],["move",60,"A","done"],["cycle_time",61,"A"],["lead_time",61,"A"]]
- Output:
- [true,true,true,true,true,true,true,true,true,true,50,60]
- Explanation
- The bounce from Review back to In Progress at minute 40 does not restart the cycle clock that began at minute 10, so cycle time is 60 - 10 = 50 and lead time is 60 - 0.
Example 2
- Input:
- [["create",0,"A","a"],["block",10,"A","vendor"],["block",15,"A","env"],["unblock",20,"A","vendor"],["move",21,"A","ready"],["unblock",30,"A","env"],["blocked_time",40,"A"]]
- Output:
- [true,true,true,true,"error: card blocked",true,20]
- Explanation
- Two overlapping blockers keep the card blocked from minute 10 to 30; overlapping minutes are counted once, so blocked time is 20, not 25.
Example 3
- Input:
- [["flow",10,"c",4,20],["percentile",200,"all",50],["percentile",200,"all",75],["percentile",200,"all",1]]
- Output:
- [4,6,11,5]
- Explanation
- Cycle times are 11, 5, 12, 6; sorted 5, 6, 11, 12. Nearest-rank p50 is rank 2, p75 rank 3, p1 rank 1.
Guidelines
- Python 3 standard library only
- All times are integer minutes since the board's epoch; ts never decreases from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- A refused operation changes nothing
- Part 3 inputs push thousands of cards through the flow operation