Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

Where a Turn Is Right Now

~15 min · master-chief, observability, ux, long-polling, compaction

Level 0Visitor
0 XP0/36 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

One Word for Every Wait

The last lessons kept noise out of the room. This one is about the opposite failure: silence that looks like something. On 2026-10-18, using voice mode on a trip abroad, Dad noticed that between his last word and her first sound the screen said one word, "Thinking". Transcribing, uploading, the speaker check, the memory search, the model's silence and its thinking all looked the same, and a stalled step pulsed exactly like a busy one. What made him uneasy was not the wait. It was a line that did not move.

"이걸 실시간으로 뭘 하고 있는지 계속 보여주면 응답이 늦어지더라도 불안하지는 않다고."
— Dad ("If it keeps showing what it is doing in real time, I am not anxious even when the answer is slow.")

In a conversation that never ends this matters more than anywhere. A Master Chief turn can wait behind another doorway's turn, a voice check, a compaction or a long tool call, and from the outside every one of those used to look like the model being slow.

The Board

Office now publishes where each turn is (services/turn_stage.py, todo #531). It is an in-memory board with one entry per conversation, and it is deliberately not a timing system of its own. The two clocks that already split a turn for the log, [turn-prep] and [turn-provider], publish to it as they mark, so the stage a screen reads is the step the log will name. In order: received, voice_check, preparing, memory, model (asked, nothing back yet), thinking and writing (the first of each), tool with the tool's name, compacting, and done.

A screen reads it with a long poll, GET /api/chat/stage/{conversation_id} with the request id and the last sequence number it saw: office answers the moment the stage moves past that number, or after at most 25 seconds. An entry belonging to another request reads as no stage at all, so a screen never shows a neighbor's turn as its own. Each entry also records when it first entered every stage, and a finished turn stays findable by its request id among the last 64 finished turns (a done entry itself leaves the board after ten minutes), because the voice turn's timeline reads its server half from the same board: the record of a turn and the line on the screen are one source. A private conversation's entry is never logged or stored.

Compaction Is a Stage

Dad added one requirement the same evening: a slow turn must never be mistaken for a compaction, or the other way round. So every compaction publishes compacting and its end with a kind that says whose it was: before_reply (the emergency compaction in front of an ordinary reply), background (Master Chief's pass between turns), manual (Dad's own, from the chat menu), and the vendors' own inside a turn: claude, read from the status the Claude CLI reports, and codex, read from the compaction items in the Responses stream the Codex adapter consumes. A background pass never masks a turn that is already running. The conversation's metadata carries compacting, last_compacted_at and the kind. The board itself lives in memory, so after a restart the metadata falls back to the newest summary row: that keeps when our own last compaction happened, but not its original kind, and not a vendor's compaction inside a turn. The phone's composer line now always says it: a compaction running, or "Last compacted" with the time and who did it (Claude, Codex, between turns, or you).

Stalled Is a Diagnosis, Not a Timer

The first version of the line still lied in one place. Out walking on a cellular connection, two spoken turns with pasted photos read "Uploading your recording · stalled, nothing for 70s" while what was crossing the link was the photos: that build called every upload a recording, and the next one counted progress only when a whole photo landed, so a 1.5 MB photo at 40 KB/s would still have read stalled while its bytes moved. Dad's rule from that walk had three parts: no step sits as one frozen word, "stalled" is a diagnosis, and the watch shows what the phone shows.

  • Evidence, not a word. Uploads report bytes and measured rate ("Uploading photo 2 of 4 · 1.1 of 3.6 MB · 40 KB/s"), naming the leg with more left to send. Thinking and writing show characters arrived. Steps that happen only on office show when office last answered the board's long poll. New evidence is progress; the same evidence is not.
  • A check before the verdict. Past a step's limit the phone checks its own link, then office (the board's answer if it is under fifteen seconds old, otherwise the health route), again every fifteen seconds, and the line says what it found: "no bytes out for 47s; office answers, the cellular link is not carrying the bytes", or that office answers and the model has not started, or that the phone has no network. Orange is for trouble only.
  • One wording. The watch receives the phone's own line for its turn, at most once a second while the watch app is in front, and ages it ("iPhone quiet 9s"). There is no second copy of the wording on the wrist, and the web voice screen reaches the same diagnosis in the same words.
A wait that shows evidence is safe; a frozen label is broken. Feed one stage source from the clocks you already keep, let every surface read that same source, and say "stalled" only after something was actually checked.

Code

Verbatim excerpt from services/turn_stage.py: the board moves on marks the existing clocks already make·python
# After the route's mark named here, the next step is this stage. The marks
# are the ``[turn-prep]`` line's own (routes/chat.py and the Ollama route
# make them in this order); a mark not listed keeps the stage it is in.
STAGE_AFTER_MARK: dict[str, str] = {
    "resolve": "voice_check",
    "speaker": "preparing",
    "between": "memory",
    "history": "memory",
    "rag_and_prompt": "preparing",
}

# ``ProviderClock.first`` stages that move the board.
STAGE_AT_PROVIDER: dict[str, str] = {
    "sent": "model",
    "first_thinking": "thinking",
    "first_text": "writing",
}

# A finished entry is kept this long for a late reader, then dropped.
_KEEP_DONE_SECONDS = 600.0
# Finished turns findable by request id (``finished``), newest last.
_KEEP_FINISHED = 64
A minimal sketch you can run: a turn publishes stages, a screen long-polls them, and a quiet step gets a check instead of a timer (stdlib, Python 3.9+)·python
"""A minimal sketch: one stage board a turn publishes to, a screen that long-polls
it, and "stalled" said only after something was checked. Not the engine."""
import asyncio
import time


class Board:
    def __init__(self):
        self.entry = {"stage": None, "detail": None, "seq": 0, "since": time.monotonic()}
        self.changed = asyncio.Event()

    def publish(self, stage, detail=None):
        self.entry = {"stage": stage, "detail": detail, "seq": self.entry["seq"] + 1,
                      "since": time.monotonic()}
        self.changed.set()                  # wake every waiter...
        self.changed = asyncio.Event()      # ...and arm the next move

    async def wait_past(self, seq, wait):
        """Answer as soon as the stage moves past seq, or after `wait` seconds."""
        if self.entry["seq"] > seq:
            return self.entry
        try:
            await asyncio.wait_for(self.changed.wait(), wait)
        except asyncio.TimeoutError:
            pass
        return self.entry


async def turn(board):
    steps = [("received", None, 0.05), ("voice_check", None, 0.2), ("memory", None, 0.3),
             ("model", None, 1.3), ("tool", "coop", 0.3), ("writing", None, 0.3), ("done", None, 0)]
    for stage, detail, seconds in steps:
        board.publish(stage, detail)
        await asyncio.sleep(seconds)


def check(link_ok, office_answered_ago):
    """A diagnosis, never a timer: look at the link, then at office."""
    if not link_ok:
        return "the phone has no network"
    if office_answered_ago is None or office_answered_ago > 15:
        return "office does not answer"
    return "office answers; the model has not started yet"


async def screen(board, limit=1.0):
    seq, start = 0, time.monotonic()
    while True:
        entry = await board.wait_past(seq, wait=0.5)
        quiet = time.monotonic() - entry["since"]
        moved = entry["seq"] != seq
        if moved or quiet > limit:
            line = entry["stage"] + (f" · {entry['detail']}" if entry["detail"] else "")
            if not moved:
                line += f" · quiet {quiet:.1f}s · {check(True, 2.0)}"
            print(f"{time.monotonic() - start:4.1f}s  {line}")
        seq = entry["seq"]
        if entry["stage"] == "done":
            return


async def main():
    board = Board()
    await asyncio.gather(turn(board), screen(board))


asyncio.run(main())

External links

Exercise

Pick the slowest wait in something you use every day: an upload, a build, an assistant reply. Write down every step that happens during it and which of those your screen tells apart today. Then add one stage line fed by the same events your logs already record, and for the one step that most often looks stuck, replace the timeout message with a check that says what it found.
Hint
Start from the log, not the screen: if your log cannot name a step, the screen should not invent one. For the check, two questions cover most cases: is my own link carrying bytes, and is the server answering at all. Say which one failed.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.