~15 min · first-steps, concurrency, idempotency, python
Level 0Visitor
0 XP0/33 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete
A Second Door Changes Everything
Step one had one terminal, so it never had to ask who goes first. The moment a second client can post into the same conversation, it does. This step adds two doorways to one conversation: the command line you already have, and a tiny HTTP endpoint that stands in for a phone or a browser. Both name the same conversation id, and both go through one function, send. That is the toy version of Track 2's one room: every door resolves the same conversation, and the engine, not the door, decides how a turn joins it.
Three Rules
The server picks the parent. A client cannot know what another client sent a moment ago, so its idea of "the message I am replying to" is stale by design. The sketch never accepts a parent from the client: every new turn hangs off the newest row in the log, whichever door wrote it. Master Chief does the same thing for every new turn, and drops whatever parent the client posted.
One turn at a time per conversation. The lock is held from reading the history all the way through writing the reply, so the next turn starts from a history that already holds the previous answer. The sketch uses fcntl.flock on a lock file beside the log, which holds across threads and across processes on one Mac or Linux machine: the server in one terminal and the CLI in another really do wait for each other. Master Chief uses a fair asyncio.Lock, so turns are admitted in the order they arrived; flock makes no promise about order, which is one of the ways this is a toy.
The client mints the request id. A phone that loses its connection cannot tell whether its message arrived, so it sends again. If the server cannot recognize the second copy, the instruction runs twice. So each client makes an id once, when the user presses send, and repeats that same id on every retry. The server checks the record for it first, and a known id gets the stored answer back with the state already_accepted instead of a second model call. Master Chief's clients mint ids with a prefix naming the surface, in shapes like tg-<chat>-<message> for Telegram and webui-<uuid> for the browser.
Run the Race
python3 step2_doors.py race starts the HTTP door in a thread, posts from it, and fires the CLI door fifty milliseconds later, while the browser's turn is still waiting on the slow fake model. It runs once without the lock and once with it, then resends the browser's request with the same id.
Read the unlocked run carefully, because this is the tangle Track 2 describes. The terminal's message hung off the browser's question, so a user row is chained to a user row, and the browser's question now has two children: a fork. Both replies missed something. The browser's reply never saw the terminal's message, and the terminal's reply never saw the browser's answer. In a conversation that is meant to last forever, a fork like that is permanent. With the lock, the chain is clean: the terminal's turn waited, and its reply saw the browser's question and answer before its own. In both runs the resend came back already_accepted and added no rows.
You can also run the doors as two real processes: python3 step2_doors.py serve in one terminal, then python3 step2_doors.py send hello or the curl line in the comment beside serve, from another.
What This Sketch Deliberately Does Not Have
This is exactly where real systems start to need everything in Track 2, and the sketch has none of it:
No turn status. If the server dies after writing the question and before the answer, a resend gets already_accepted with no reply, forever. The next turn then hangs off that unanswered question, which is why the resend lookup takes only an assistant child: any child at all would hand back the next turn's question as if it were the answer. Master Chief can say a turn is pending while it waits, and tells a turn withdrawn by Stop apart from one interrupted by a shutdown, which the client should simply send again.
No Stop, no shutdown grace. A queued turn cannot be withdrawn, and killing the server kills whatever is in flight. A restart doing exactly that was finding #2 of the cold review.
No admission. Anyone who can reach the port can post into the conversation. Nothing refuses a private turn, an automated job, or another person. Nothing decides when a thread becomes a doorway.
No timeouts. A model call that hangs holds the lock, and every door waits behind it.
One machine. The lock file lives on one disk. Two servers on two machines would need a different answer entirely.
The client never chooses its place in the conversation; it only names itself. The server picks the parent, the lock picks the order, and the request id lets a client ask "did you get this?" without asking for it twice.
Code
A minimal sketch you can run: step2_doors.py (written for this lesson, stdlib only, macOS/Linux; call_model is the stub you fill in)·python
"""step2_doors.py: two doorways (a CLI and a tiny HTTP endpoint), one conversation.
New turns always hang off the newest row, one turn at a time per conversation,
and a client-minted request id makes a resend return the first answer.
Stdlib only, macOS/Linux (fcntl). Run: python3 step2_doors.py race
Or two terminals: python3 step2_doors.py serve and python3 step2_doors.py send hi
"""
import fcntl, json, sys, threading, time, urllib.request, uuid
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
DATA = Path("doors-data")
LOCKED = True # the race below runs once without it
def call_model(system, messages):
"""STUB: your one model call goes here. The fake is slow on purpose,
so two doorways arriving together really do overlap."""
time.sleep(0.3)
return f"(fake) I saw {len(messages)} message(s)"
def load(conv):
p = DATA / f"{conv}.jsonl"
return [json.loads(l) for l in p.read_text().splitlines() if l] if p.exists() else []
def append(conv, row):
with (DATA / f"{conv}.jsonl").open("a") as f:
f.write(json.dumps(row) + "\n")
@contextmanager
def conversation_lock(conv):
"""One turn at a time per conversation, across threads AND processes."""
if not LOCKED:
yield
return
with (DATA / f"{conv}.lock").open("w") as f:
fcntl.flock(f, fcntl.LOCK_EX) # blocks until the turn ahead is saved
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def send(conv, text, request_id, door):
"""Every doorway calls this. The client never chooses the parent."""
DATA.mkdir(exist_ok=True)
with conversation_lock(conv):
rows = load(conv)
for r in rows: # a resend: answer from the record, run nothing
if r.get("request_id") == request_id:
reply = next((x for x in rows if x["parent"] == r["id"] # its answer, not
and x["role"] == "assistant"), None) # a later question
return {"state": "already_accepted", "reply": reply and reply["content"]}
parent = rows[-1]["id"] if rows else None # the newest row, from any door
user = {"id": uuid.uuid4().hex[:8], "parent": parent, "role": "user",
"content": text, "door": door, "request_id": request_id}
append(conv, user)
replay = [{"role": r["role"], "content": r["content"]} for r in rows + [user]]
reply = call_model("You are Pip.", replay)
append(conv, {"id": uuid.uuid4().hex[:8], "parent": user["id"], "role": "assistant",
"content": reply, "replayed": len(replay)})
return {"state": "done", "reply": reply}
class Door(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
data = json.dumps(send(body["conversation_id"], body["text"],
body["request_id"], "http")).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
def post(port, conv, text, request_id):
body = json.dumps({"conversation_id": conv, "text": text, "request_id": request_id})
req = urllib.request.Request(f"http://127.0.0.1:{port}/chat", data=body.encode())
return json.loads(urllib.request.urlopen(req).read())
def race(locked):
global LOCKED
LOCKED, conv = locked, f"race-{locked}"
(DATA / f"{conv}.jsonl").unlink(missing_ok=True)
server = ThreadingHTTPServer(("127.0.0.1", 0), Door)
threading.Thread(target=server.serve_forever, daemon=True).start()
port = server.server_address[1]
web = threading.Thread(target=post, args=(port, conv, "sent from the browser", "web-1"))
web.start() # door 1: HTTP
time.sleep(0.05)
send(conv, "sent from the terminal", "cli-1", "cli") # door 2: CLI, a moment later
web.join()
rows = load(conv)
text = {r["id"]: r["content"] for r in rows}
print(f"LOCKED={locked}")
for r in rows:
print(f" {r['role']:9} {r['content']!r:30} after: {text.get(r['parent'], '(start)')!r}")
parents = [r["parent"] for r in rows]
forks = len(parents) - len(set(parents))
blind = sum(1 for i, r in enumerate(rows) if r["role"] == "assistant" and r["replayed"] < i)
print(f" forks: {forks} replies that missed an earlier message: {blind}")
again = post(port, conv, "sent from the browser", "web-1") # the same request, resent
print(f" resend web-1 -> {again['state']}, rows still {len(load(conv))}")
server.shutdown()
if __name__ == "__main__":
DATA.mkdir(exist_ok=True)
cmd = sys.argv[1] if len(sys.argv) > 1 else "race"
if cmd == "race":
race(False)
race(True)
elif cmd == "serve": # curl -d '{"conversation_id":"default","text":"hi","request_id":"r1"}' 127.0.0.1:8765
ThreadingHTTPServer(("127.0.0.1", 8765), Door).serve_forever()
elif cmd == "send":
print(send("default", " ".join(sys.argv[2:]), "cli-" + uuid.uuid4().hex[:8], "cli")["reply"])
What the race printed on a real run (identical across five repeated runs)·text
$ python3 step2_doors.py race
LOCKED=False
user 'sent from the browser' after: '(start)'
user 'sent from the terminal' after: 'sent from the browser'
assistant '(fake) I saw 1 message(s)' after: 'sent from the browser'
assistant '(fake) I saw 2 message(s)' after: 'sent from the terminal'
forks: 1 replies that missed an earlier message: 2
resend web-1 -> already_accepted, rows still 4
LOCKED=True
user 'sent from the browser' after: '(start)'
assistant '(fake) I saw 1 message(s)' after: 'sent from the browser'
user 'sent from the terminal' after: '(fake) I saw 1 message(s)'
assistant '(fake) I saw 3 message(s)' after: 'sent from the terminal'
forks: 0 replies that missed an earlier message: 0
resend web-1 -> already_accepted, rows still 4
Run the race, then break each rule on purpose, one at a time. First, let the client choose its parent (pass the parent it last saw) and keep the lock: what goes wrong? Second, mint a fresh request id inside post() on every attempt and call it twice: how many rows do you get? Third, kill the server with Ctrl-C while a turn is sleeping in call_model, restart it, and resend the same request id. Write down what the client sees in each case.
Hint
The third case is the interesting one: the question is in the log, the answer is not, and the resend finds the question and answers already_accepted with no reply. Ask yourself what the client should do next, and what the server would need to record to tell it. That is the problem Track 2's turn states exist to solve.
Progress
Progress is local-only — sign in to sync across devices.