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

Generations and Shared Checkouts

~15 min · fencing, concurrency, git, coordination

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A Session Can Outlive Its Own Authority

The adapter that follows a chat is a child process on office. It can be restarted, replaced, or left behind when an enrollment ends and the same chat is later enrolled again. Through all of that the chat's native id stays the same, so identity alone cannot tell the current adapter from a stale one still holding an old envelope. Coop adds a number for exactly that: the session's generation.

Generation as a Fencing Token

A session row starts at generation 1, and it goes up in two places. When register enrolls a retired session again, the code says why in a comment: it "starts a new generation, which fences anything still acting for the old one." And when a chat that took work through the CLI is adopted into a managed enrollment, adopt_take bumps it too.

Everything the engine hands out is stamped with the generation it was issued under: dispatches, bindings, outbox envelopes. Everything the adapter asks for names a generation: accept, decline, report, delivery receipts, status observations. And one small helper, _session, which accept, decline, report, notes and deliveries all pass through, refuses any call whose generation is not current: "Stale or exited session generation." Status observations make the same comparison on their own. A report goes one step further and also requires the binding itself to belong to this session and generation and still be active. The architecture's failure table puts it plainly: an old generation's report is a conflict, and the current owner's state is preserved.

The fence works from both sides. The adapter only delivers envelopes of its own generation ("Host queue does not own this envelope"), and re-enrollment rewrites its adapter file with the new number, "or the runtime would keep refusing the stale one." When the engine starts, cancel_fenced cancels any pending envelope stamped with an older generation than its session's current one, recording the reason fenced-generation: such a row can never be sent, and left alone it would read as "still being delivered" for ever and hold the enrollment open. Unread replies get a fresh review at the current generation instead. Adoption is the deliberate exception: there, envelopes queued while nobody watched the chat move forward to the new generation, because they still belong to that same chat.

This is Martin Kleppmann's fencing token. A process that holds a lock can pause, lose its lease without knowing, wake up and write as if nothing happened. The fix is a number that grows every time the right is granted, checked by the storage, which rejects a write carrying an older number. Coop's generation is that number and the engine is that storage. The point is where the check lives: in the store, not in the stale process's good manners.

Two Sessions, One Checkout

Dad routinely runs several sessions in one repository on different items. On 2026-09-30 (todo #285, commit 43ddac9) a new Claude chat in one checkout answered READY and sat idle. Discovery had found it in six seconds, but prepare refused the dispatch, because a Codex session assigned #284 three minutes earlier held the same checkout as its write scope, and the failure reached only Telegram. Every sequential launch into one repository after the first failed the same way.

The fix flipped the rule: an overlapping write scope between sessions is disclosed, not refused. At accept, shared_checkout lists every other session whose live dispatch declares a write scope overlapping this one (either path inside the other), with its vendor, actor, status, scope and the titles of its items. Its docstring is the whole policy: "A disclosure, not a lock." A dispatch from the same session and generation is skipped, since one native owner cannot collide with itself, and a duplicate dispatch of the same item is still refused. When the list is non-empty, the work envelope adds this instruction:

"Other sessions are working in this same checkout right now (shared_checkout lists them and their tasks): never revert, stash, reset, clean or commit their changes; stage and commit only files you changed, by explicit path; if a file you need is also being changed by another session, ask Dad before touching it."

The real guards stay where they were: the family's git discipline and executor-owned locks, such as Firelink's deploy lock.

No Chat Waits at READY for Nothing

The same commit added a new envelope kind, the notice. When an assignment fails after its chat was connected, the chat is told: "Coop could not hand this chat its task (…): <reason> Nothing was claimed, and Dad has been notified. Wait for Dad's instruction; do not start the task on your own." The adapter frames it as "A notice from cwkCoop; it asks for nothing but an acknowledgement." A notice owes nothing, so it never holds an enrollment open, and any still pending is cancelled when the enrollment retires.

Fence what can go stale; disclose what you cannot prevent. A generation stops the past from acting in the present. A disclosure lets two present sessions share a tree without pretending they are alone. A lock on the checkout would have been simpler to write, and wrong for the way Dad actually works.

Code

Trimmed excerpts with orienting comments added: where the generation is checked·python
# engine.py: accept, decline, report, notes and deliveries all pass through this
def _session(self,conn,sid,generation):
    session=self.row(conn,'engine_sessions',sid)
    if session['generation'] != generation or session['status'] == 'exited':
        raise Conflict('Stale or exited session generation')
    return session

# engine.py, Engine.cancel_fenced (run when the engine starts): an older
# generation's pending envelope can never be sent, so it is cancelled
cancelled = conn.execute('''UPDATE engine_outbox SET state='cancelled',receipt=? WHERE state='pending' AND session_id IS NOT NULL
    AND generation < (SELECT generation FROM engine_sessions s WHERE s.id=engine_outbox.session_id)''',
    (dump({'not_delivered': {'reason': 'fenced-generation'}}),)).rowcount

# adapters/host.py, Coordinator.process: an adapter delivers only its own generation
sid,generation=self.session['id'],self.session['generation']
if (outbox['session_id'],outbox['generation'])!=(sid,generation):
    raise Conflict('Host queue does not own this envelope')
Runnable: the overlap test Engine.shared_checkout applies to two dispatches' write intents·python
from pathlib import Path

def overlaps(mine, theirs):
    # The same expression Engine.shared_checkout uses (Python 3.9+).
    return any(Path(a).is_relative_to(b) or Path(b).is_relative_to(a) for a in mine for b in theirs)

repo = '/work/myrepo'
cases = {
    'same checkout':      ([repo], [repo]),
    'subdirectory of it': ([repo], [repo + '/docs']),
    'sibling repository': ([repo], ['/work/myrepo-site']),
    'investigation only': ([repo], []),
}
for label, (mine, theirs) in cases.items():
    print(f'{label:20} {overlaps(mine, theirs)}')
# same checkout        True
# subdirectory of it   True
# sibling repository   False   (a path test, not a string prefix test)
# investigation only   False   (no write intent, nothing to disclose)

External links

Exercise

Two parts. First, in a scratch repository, make edits as two different "sessions" (two terminals, two agents, or just two sets of files), then commit one session's work with git add -A and look at what the commit swept in. Redo it staging by explicit path. Second, add a generation counter to any small worker registry you own: re-registering a worker bumps the number, every write from a worker carries the number it was registered with, and the store rejects a write with an older number. Suspend a worker mid-task (Ctrl-Z in its terminal, or kill -STOP on its pid), register a fresh instance under the same name, then resume the old process (fg, or kill -CONT) and let it try to write.
Hint
The registry check is one comparison, but put it where the write lands (the database row, the API handler), never in the worker. A worker that is stale does not know it is stale, which is the whole reason the token exists.

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.