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

A Request Runs Once — Even When the Worker Dies

~15 min · idempotency, request-id, fsync, execute-once, retries

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"If a worker ends after an effect but before the Kit reply is saved, the same request either returns its saved native result or is held for status inspection; it cannot turn an ambiguous retry into another toggle." — Tinker architecture

Why Toggles Make Retries Dangerous

Networks and processes fail in the middle. A caller that sent a request and heard nothing back cannot tell whether the request never arrived, arrived and failed, or arrived, succeeded, and lost its answer on the way back. The standard response is to retry. For a read, a retry is harmless. For "turn editing mode on", a retry is harmless too — apply on an active preset does nothing. But for toggle, a retry after a lost answer flips editing mode straight back off. Any action whose effect depends on current state turns "retry on silence" into a bug.

The Kit's Rule: Create Actions Need an ID

The family contract handles this with request IDs. Every action declared with effect create — in Tinker, every preset apply, restore, toggle, every command.run and release — must arrive with a request_id, or it is refused. After a successful run the kit writes the answer to a small file keyed by action and request ID, and a later call with the same pair returns that saved answer, marked as a replay, without running anything.

The Gap the Kit Leaves

Look at when that file is written: after the handler returns. If the worker process dies between Tinker hiding the apps and the kit saving its answer — killed by the watchdog, by a crash, by a logout — there is no saved answer, and the retry runs the handler again. For a toggle, that is the exact bug the ID was meant to prevent. Tinker closes the gap one layer down with its own execute_once, used for every create action. Before the native call it writes an execution record with phase pending, opened with exclusive-create and flushed with fsync. After the call returns it writes phase complete with the result, through a temporary file and an atomic rename.

Three Possible Retries

A retry with the same request ID now finds one of three things. Nothing: the first attempt never reached native execution, so this one runs. A complete record: it returns the saved result without running. A pending record: the first attempt reached native execution and nobody knows how it ended, so the retry is refused with a message telling the caller to read Tinker's status and, if something still needs doing, issue a deliberate new command with a new ID. The answer to an ambiguous situation is a question, not a second toggle.

Records Don't Pile Up Forever

One file per request would grow without end. Each execution prunes completed records older than 30 days. A pending record is never pruned automatically: it is the evidence of an interrupted effect, and like an uncertain journal receipt, age does not make it safe to forget.

Code

execute_once: pending before the effect, complete after (from bridge/worker.py)·python
import hashlib, json, os
from kit_app_actions import AppActionRefused

def execute_once(call, name, args, request_id, root):
    """Hold an interrupted native effect even before Kit can save its result."""
    directory = root / 'executions'
    directory.mkdir(parents=True, exist_ok=True)
    prune_executions(directory)
    key = hashlib.sha256(json.dumps([name, request_id]).encode()).hexdigest()
    path = directory / (key + '.json')
    if path.exists():
        saved = json.loads(path.read_text())
        if saved.get('phase') == 'complete':
            return saved['result']
        raise AppActionRefused(
            'This request already reached native execution and will not run twice. '
            'Read Tinker status before issuing a deliberate command with a new request_id.')
    with path.open('x') as stream:              # exclusive create
        json.dump({'phase': 'pending', 'action': name, 'request_id': request_id}, stream)
        stream.flush(); os.fsync(stream.fileno())
    result = call(name, args)                   # the native effect
    temporary = path.with_suffix('.tmp')
    with temporary.open('w') as stream:
        json.dump({'phase': 'complete', 'result': result}, stream)
        stream.flush(); os.fsync(stream.fileno())
    os.replace(temporary, path)                 # atomic rename
    return result
Pruning: completed records after 30 days, pending ones never·python
import json, os, time
from pathlib import Path

EXECUTION_RETENTION_SECONDS = 30 * 24 * 3600

def prune_executions(directory, now=None):
    cutoff = (time.time() if now is None else now) - EXECUTION_RETENTION_SECONDS
    for entry in os.scandir(directory):
        try:
            if not entry.name.endswith('.json') or entry.stat().st_mtime >= cutoff:
                continue
            if json.loads(Path(entry.path).read_text()).get('phase') == 'complete':
                os.unlink(entry.path)
        except (OSError, ValueError):
            continue
What a caller sends for a toggle (illustrative envelope)·json
{
  "actor": {"kind": "pippa", "brain": "claude-code-pippa"},
  "request_id": "edit-toggle-0001",
  "args": {"id": "edit"}
}

External links

Exercise

Classify each of Tinker's door actions — status, preset.list, preset.save, preset.delete, preset.apply, preset.toggle, command.run, binding.set, release — by what a blind retry after a lost answer would do. Which ones need execute_once, which are safe without it, and why does preset.save not need it even though it writes?
Hint
Reads are safe. preset.save and binding.set are 'set this value' writes: running them twice leaves the same result, so they are naturally idempotent and declared as update, not create. preset.delete twice is harmless: removing an id that is no longer there changes nothing and saves cleanly. Apply and restore are idempotent on Tinker's side but still create executions; toggle and command.run (a capability command is itself a toggle) are the dangerous ones, and release must not be confused with a second release after Dad resumed. That is why every create action goes through execute_once.

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.