The states a ticket passes through

The board has three lanes. Open · In progress · Done. Those three pair up exactly with three files. That is why there is no handle for dragging a card between them. A lane is not a slot a person decides on; it is a drawing of a filename suffix.

Board laneFileWhat it means
Open<hash>.mdOpen, and a candidate for the next tick
In progress<hash>.wip.mdSomebody claimed it
Done<hash>.done.mdFinished

Those three are all the states there are. When a worker claims a ticket the filename gains .wip and the card moves to the lane on the right. When the session ends by changing it to .done, it goes one lane further. The app reads that file and draws it; it does not make the state.

The suffix strings themselves are changed with the environment variables TICKET_INPROGRESS and TICKET_DONE (defaults .wip and .done; see Worker environment variables). Whatever strings you use, the decision works the same way. It looks only at whether the filename ends with that string.

Done that closed unfinished

The done badge comes in two shapes. Done and Done (continued). The file is the one shape, <hash>.done.md, in both cases; what splits them is whether there is a continued: line at the top of the ticket. It is not that there are now four states.

Done (continued) is a done that closed by handing the rest to another ticket. When a session decides it will not get this ticket all the way finished, it does not die on the spot. It leaves what it got done and opens one new ticket for the remaining scope. It writes that hash into its own continued: and closes as done. So a ticket wearing this badge has Done when items left unticked. Those are not spots the session missed. The follow-on ticket inherits those items as they stand.

The badge itself is the link to that follow-on ticket. Press it to jump across and the remaining scope is in that ticket's ## Goal. If the hash written in continued: is not in the queue, the badge appears without the link.

The chain catches at three. A handoff ticket carries which number it is in a handoffs: line. One after the original, then two, adding up one at a time. At the fourth, the worker that claims the ticket puts it straight back down and locks it as awaiting an answer. The point is to get a ticket that has been passed on and on with nobody finishing it in front of a person at least once. Write an answer and the lock comes off, and the same ticket does not hit this ceiling again.

The badge shows in the status column of the board table and next to the title on the ticket detail. The status filter does not have this split. Pick Done and both come out.

This is not the follow-up a person opens by writing a line on a done ticket. That one only reads the original, so the original's badge stays plain Done.

Three markings that are not lanes

The lanes draw the steps a ticket passes through. But among the tickets sitting in Open there are some that cannot move on when their turn comes. It is not one more step so much as a circumstance inside that step, so instead of stretching the lanes to four, a marking goes on the card to tell you.

Blocked — the orange deps tag. A prerequisite ticket has not finished. The unmet hashes show on the card in orange and the ticket stays exactly where it is in the Open lane. Being stuck does not push it to the bottom. Pushing it down would make the cards above it lie about being "dispatched soon." There is nothing for you to do. The moment the prerequisite finishes, the tag disappears and that ticket becomes a candidate, with nobody touching it.

Awaiting answer — a badge and the days elapsed (Awaiting answer · 3d). The session asked a person something and took its hands off. There are two places the engine locks a ticket even when the session did not ask: when automatic reclaim has failed three times, and when the handoff ceiling above is hit. Either way that ticket is locked until an answer exists as a file, so no worker picks it up. It is a lock put there to keep from burning sessions on the same question over and over. There are three places to write an answer and one form between them. In the Progress record box on the ticket detail the input field comes up in answer mode. On the board card it opens as a dialog, and the notification bell in the header carries a Write an answer link. Wherever you write it, the app makes one kind: answer file. The instant that file is born, the lock comes off and the badge goes back to Open.

The days elapsed ride along on the badge to make neglect visible. Nothing expires and nothing cancels on its own. An old Awaiting answer is a sign of a stall. That call is a person's.

Polling — a badge and the time left (Polling · 3h left). This ticket is waiting on a condition outside the queue. Waiting for a build to end, for another team to fill a value in. The session hangs one script that decides the condition on the ticket and takes its hands off. After that, every time a worker wakes it runs the script on the ticket's behalf, and when the script says the condition has arrived, the badge disappears and the ticket becomes a candidate with nobody touching it. It does not hold a worker slot while it waits, either.

What splits this from Awaiting answer is whether there is an answer for a person to write. Awaiting answer never comes undone until a person writes one. Polling has no answer for a person to write. Whether the condition arrives is something happening outside the queue, and when it does, it comes undone by itself. So when you see this badge there is usually nothing for you to do.

The one Open lane holds Blocked and Awaiting answer and Polling side by side. What splits the three without opening a card is the word on the badge.

What it is waiting for, and why, is the one line under the badge. It is one sentence for people to read — waiting on an outside build to finish, say — and it gets cut where the card ends. Open the ticket and the same sentence sits in the Reason row of the Polling section.

That line comes out of the ticket body. The screen copies the first line of the last ## 결과 section, the results section. The session that hangs the wait and takes its hands off writes it there in one sentence; that is the protocol. On a ticket that has waited more than once, the sentence from the last round is the reason now. On a ticket where the protocol was not followed, the line simply does not appear. The card carries only the badge and the detail has no Reason row.

The condition itself is not written on the card. The reason is a sentence a session wrote; the condition is what the script decides, and they are not the same thing. Boiled down to one line, it gets confusing which of the script and that line is the real one. The body of the script appears whole in the Polling section of the ticket detail. The interval and the deadline, the last time it ran, and the last output are all there with it.

Polling has an end fixed to it instead. The time on the badge tells you how much is left before that deadline. Past it, the badge turns into Deadline passed, and on the next tick the engine stops polling. As it does, it moves that ticket to Awaiting answer. What it was waiting for and what the last poll saw get quoted into the question. There are three things to choose from: extend the deadline and wait longer, dispatch it now regardless of the condition, or close this ticket. The field for writing your own is underneath, as on any question. Three polling runs ending in an error in a row come up in the same place.

If you would rather not get that far, break the wait before the deadline. Dispatch now and Extend deadline always sit in the Polling section of the ticket detail (see The screens). Neither handle makes an answer file. Nobody asked a question, so there is no answer to write.

Blocked and Awaiting answer are options in the board's status filter as well. There are six filter options against three lanes, and that is correct. The filter is where you pick a state the engine knows about; the lane is where the steps of the flow get drawn. Polling is not among the six. The engine still counts this ticket as an open one, so the filter catches it under Open, and what it is waiting on right now is what the badge and the line beneath it tell you.

Tickets that never get dispatched

There are five reasons a ticket sits open with no worker claiming it. The three above — Blocked and Awaiting answer and Polling — are normal. They open by themselves when the outside condition clears.

The fourth is normal too. Priority 1 is a candidate only while zero tickets are in progress. If anything else is running, its turn comes and it gets skipped, and when that session ends it becomes a candidate again with nobody touching it. No badge, no tag. The only marking is the priority meter on the card standing at one notch. A 1 that has inherited a higher value comes up at that value and does not catch on this gate (see Writing a ticket yourself).

Only the fifth is an accident. An open file with a session_id written in it (the badge reads Assigned), a combination the engine does not make. It happens when a person wrote session_id into the file by hand, or copied a ticket holding that value wholesale into a new ticket. Then this follows.

  • The side that picks candidates sees a session_id, takes it as already assigned, and skips it permanently.
  • The side that reclaims dead sessions looks only at .wip. This ticket is not .wip, so it is not up for reclaiming either.

Neither claimed nor reclaimed, it dies quietly. A badge alone would mean never knowing unless you opened that screen, so the app raises this as an item inside the notification bell in the header: Tickets no one will claim: <n>.

  • It shows wherever you are in the project. Put it on the board only and someone looking at a worker screen never knows.
  • It cannot be filtered off and is not cut to a top few. Every affected ticket is listed and each row gets an Unassign button. What that button does is one thing: delete the session_id.
  • There is no dismiss and no mark-as-read. The board re-reads the queue every five seconds, so when the count reaches zero the item disappears on its own. The app does not record "resolved" separately. The moment it did, there would be a second truth able to diverge from the fact in the file.

These tickets are in no kanban lane. So the board's ticket total can be larger than the lane totals, and Not dispatched: <n> — see notifications is written in alongside it.

session_id and the other keys the dispatcher and the engine use are not values a person fills in. Which keys exist and who writes them when is a table in frontmatter fields.

Why the filename is the lock

There is one reason the state lives in the filename instead of the frontmatter. The rename is itself the lock, so several workers can watch the same queue without colliding.

A claim is os.link making <hash>.wip.md and deleting the original. If the destination already exists, the kernel hands back EEXIST. Already claimed. The decision ("not claimed yet?") and the acquisition ("I claim it") are inside one system call, so there is no gap for another process to get in between. Of six workers looking at the same ticket at once, only the one that linked first wins, and the rest move on to the next candidate. This is not a newly invented lineage. It is the same as Maildir, which uses the rename of a mail from new/ to cur/ as the lock.

On a filesystem with no hard links (a FUSE or SMB mount, Google Drive say) it falls back to O_CREAT|O_EXCL. That is an atomic exclusive create too, so the property holds. Move the state into the frontmatter and the gap between reading a value and writing it back is not atomic across processes, and you have to buy a lock the filename was giving away.

Next is Workers.