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

Retired, Woken, Never Stopped

~15 min · process-lifecycle, supervision, ownership, coordination

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

Eight Hours to the Minute

On the evening of 2026-10-14 a Coop worker for todo #423 reported itself blocked: it had a question only Dad could answer, and it parked at its prompt to wait for him. At 20:10 UTC it went quiet. At 04:10 UTC the next morning, eight hours to the minute, it was gone. claude.ai showed "Remote Control disconnected" for good, and in the same second Coop's adapter logged that the chat was closed and its enrollment kept.

Coop had not touched it. No Coop path stops a native chat. The cause was Claude Code's own background daemon, which retires a background chat that sits idle at its prompt: a Remote Control chat after eight hours without input, any other after one hour, and within a minute when the Mac is short of memory. It leaves one line in the daemon's log, bg retire <short>: idle-prompt, idle 8h, and ends the process.

From the daemon's side that is good housekeeping: an idle chat is using memory for nothing. From Coop's side it is a specific kind of loss. The chat that retired was the one place Dad's answer was supposed to go. The work was still bound to it, the obligation still owed, and Dad's reply, when it came, had nowhere to land.

What Retirement Keeps

The fix started with a measurement of what retirement actually destroys, and the answer was: less than it looks. The transcript stays. The daemon's job record for the chat stays, with the launch options it was started with. And the Remote Control session stays.

Then the probe that decided the design (2026-10-16): claude --bg --resume <session-id>, with no other flag, wakes that same session with its saved options, under the same id and the same claude.ai link, which the probe checked before and after. Add any other flag and the CLI starts a copy under a new id instead. So the wake command is exactly those three words and the id. Everything else the chat needs, including the two worker flags from the last lesson, comes back from the job record.

The Waker

Each adapter child now carries a small Waker (in revival.py) for the one chat it follows. Its rule, in order:

  1. Only a chat that still owes work. If the enrollment owes nothing, a retired chat is simply finished.
  2. Only a background chat. The chat must have a daemon job record whose session id matches. A Desktop or terminal chat has none and is never woken; only Claude Code itself can reopen it.
  3. Only after a minute gone (GONE_GRACE). The daemon has its own crash respawn, and it should win that race.
  4. At most once per fifteen minutes (RETRY) while the chat stays gone.
  5. Verify what came back. The CLI prints which short id it backgrounded. If that is not the chat's own, the CLI started a copy, and Coop stops and removes it at once, so no second chat ever answers for the first.

Every attempt leaves a revived or revive_failed line in the runtime's log, and each distinct failure is logged once rather than every fifteen minutes. The sentence that keeps this safe is about what a woken chat does: nothing. It comes back idle at its prompt. Waking it restarts no work by itself. The adapter then finds the live chat by its session id and delivers whatever is still owed, Dad's replies included, through the same doors as before.

Owed Has to Mean Owed

The waker turned a quiet bug loud within hours. "Still owes work" was computed by Engine.owed, and some of what it counted could never be paid. A reply review sent for a binding that had since resolved could not be acknowledged: the engine refuses a COOP_READ for it ("Reply review no longer has an active binding"). An invitation or work envelope sent for a dispatch that had since finished would never be answered either. Each sat in the outbox as sent, receipt uncertain, and each counted as owed for ever.

Four finished chats had been held enrolled that way, some since 2026-10-05. Before the waker that only kept an adapter polling. With the waker, every one of them would have been woken every time the daemon retired it, to deliver something nobody could ever receive. The same morning owed learned to ignore such rows; two days later the engine stopped leaving them open at all: it now cancels them in the same transaction that ends the work (Track 6's last lesson tells that part).

The Reaper That Hid Finished Work

The waker shipped with a partner. If daemon retirement was going to be undone for chats that owe work, finished Coop chats should be cleaned up deliberately, so a Reaper in the runtime ran every ten minutes and stopped any Coop chat whose enrollment had retired, that owed nothing, that no active enrollment used, and that had sat idle, not waiting on a person, for two hours. Idle enrollments were made to retire after two hours instead of six to match.

It lasted two days. claude stop does more than end a process: it removes the chat from the Claude app's session list and from its search, even though the transcript and the job record stay on disk. Dad went looking for finished work, including the chat that had built every-brain Coop, and could not find it. The runtime's log held 49 such stops on office between 2026-10-16 and 10-18. A chat the daemon retires on its own (settled, idle 8h) stays listed.

So todo #518 removed the reaper, its stop helper, the receivers' reap hook and the runtime's reap loop, and a test now pins that no receiver or runtime path reaps. Idle enrollment retirement stayed: after two idle hours with nothing owed, Coop ends its own record and the adapter child that follows the chat, and never touches the chat. The only claude stop left anywhere in Coop removes a copy a wake started by mistake.

End your own records; leave the other system's objects to their owner. Coop owns enrollments, adapters and obligations, so it may retire them on its own clock. The chat belongs to Claude Code and, through it, to Dad. Coop may wake one that still owes him something, by the narrowest command that cannot create a second one, and it never ends one at all.

Code

From Coop's revival.py: wake the exact chat, and refuse a copy (trimmed excerpt)·python
def revive(host, session_id, job=None):
    """Wake the exact background chat `session_id` with its saved options; the woken short id."""
    job = job or job_record(host, session_id)
    if not job:
        raise RevivalFailed('Not a background Claude chat; only Claude Code itself can reopen it')
    cwd = job.get('cwd')
    if not cwd or host.stat(cwd) is None:
        raise RevivalFailed(f'The chat\'s folder {cwd} is gone')
    result = _cli(host, cwd, ['--bg', '--resume', session_id])   # no other flag, ever
    text = _output(result)
    found = BACKGROUNDED.search(text)
    if not found:
        raise RevivalFailed('claude --bg --resume did not say which session it backgrounded: ' + text[-300:])
    short = found.group(1)
    if short != session_id[:8]:
        for verb in ('stop', 'rm'):                              # the only stop left in Coop
            try:
                _cli(host, cwd, [verb, short], 30)
            except Exception as exc:
                log(revival='copy_cleanup_failed', session=session_id, copy=short, verb=verb, detail=str(exc)[:200])
        raise RevivalFailed(f'claude started a copy {short} instead of waking {session_id[:8]}; the copy was removed')
    return short
A minimal sketch you can run: a grace period, a retry window, and a copy that is refused·python
"""A minimal sketch (not Coop's code): wake a lost chat that still owes work,
after a grace period, at most once per retry window, and never keep a copy."""
GONE_GRACE, RETRY = 60, 15 * 60


class Waker:
    def __init__(self, resume):
        self.resume = resume          # returns the short id the CLI backgrounded
        self.gone_since = None
        self.not_before = 0

    def tick(self, now, present, owes, session='a1b2c3d4-0000'):
        if present or not owes:
            self.gone_since = None
            return 'nothing to do'
        if self.gone_since is None:
            self.gone_since = now
        if now - self.gone_since < GONE_GRACE:
            return 'grace: the daemon may respawn it first'
        if now < self.not_before:
            return 'waiting for the next window'
        self.not_before = now + RETRY
        short = self.resume(session)
        if short != session[:8]:
            return f'copy {short} started: stop and remove it'
        return f'woke {short}: same id, same link'


calls = iter(['a1b2c3d4', 'ffff0000'])
waker = Waker(lambda session: next(calls))
for now, present, owes in [(0, True, True), (10, False, True), (50, False, True),
                           (80, False, True), (200, False, True), (1000, False, True),
                           (2000, False, False)]:
    print(f'{now:5}s present={present!s:5} owes={owes!s:5} ->', waker.tick(now, present, owes))

# Expected output:
#     0s present=True  owes=True  -> nothing to do
#    10s present=False owes=True  -> grace: the daemon may respawn it first
#    50s present=False owes=True  -> grace: the daemon may respawn it first
#    80s present=False owes=True  -> woke a1b2c3d4: same id, same link
#   200s present=False owes=True  -> waiting for the next window
#  1000s present=False owes=True  -> copy ffff0000 started: stop and remove it
#  2000s present=False owes=False -> nothing to do

External links

Exercise

Pick a supervisor you rely on that restarts or cleans up processes for you: a process manager, a container orchestrator, a scheduler that kills idle jobs, a chat app that archives old threads. Write down, for each of its cleanup verbs, three things: what it frees, what it keeps, and what a person can no longer find afterwards. Then run the sketch above and change it so the resume function sometimes returns the original id after printing a warning. Does your copy check still hold?
Hint
The dangerous verbs are the ones whose side effects land on a different screen from yours: removing a job from a list, archiving a thread, deleting a log stream. Look for the verb that is cheap in the API and expensive in the UI. In the sketch, the comparison that matters is between the id you asked to wake and the id that came back.

Progress

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

Comments 0

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

No comments yet — be the first.