DEV Community

Harper Xu
Harper Xu

Posted on

Replay the Job, Not the Chat

Chat history is the wrong control plane for generation. You need a job you can replay later.

A chat is a river that only flows forward. A job is a crate you can restack after a crash.

Most coding agents still behave like chat rooms. They retry by appending more words to a transcript. That design fails under cancel, timeout, and double submit.

You already know this pattern from payment APIs. Clients send an idempotency key with every charge. The server refuses a second write for the same key.

Treat a model call as a write with a key. Do not treat it as a conversation you continue.

Constraints before tools

Three hard constraints sit under this job architecture. The job document is immutable once you enqueue it. The generator may crash, but it may not edit the job.

The receipt is the only success signal you trust. Logs are evidence. They are not permission to apply.

The apply host has one writer for a given path. Two receipts cannot win the same file without a merge. If both land, you have a split brain.

Think of the generator as a day laborer. You hand over a work order, not house keys and a diary. The laborer returns a crate with a packing slip.

You inspect the slip first. You do not unpack into production on vibes.

Data flow you can point at

Here is the data flow you actually want. Your planner never calls the model in-process.

It writes a job file to a local queue directory. That file names the allowed paths and the instruction. It also names an idempotency key you minted.

A worker picks the job and ships a minimal bundle. The remote generator sees those files, not your chat memory. It also never sees your apply credentials.

The worker waits for an artifact and a receipt. Your verifier checks the receipt before any apply step. Failed checks leave the tree untouched.

If the worker dies, you replay the same job file. You do not invent a follow-up prompt. Replay is the recovery path, not clever chat.

A disposable remote fits this worker role. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option you can aim the worker at for scratch generation. Keep secrets off that host. Keep the queue and the apply step on machines you control.

A job you can hash twice

Label this example as a proposed local contract. It is not a production service. Run it against a toy repo first.

# genjob.py
from __future__ import annotations

import hashlib, json, time, uuid
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Mapping

SCHEMA = "genjob.v1"


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_file(path: Path) -> str:
    return sha256_bytes(path.read_bytes())


def canonical(obj: object) -> bytes:
    return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()


@dataclass(frozen=True)
class GenerationJob:
    schema: str
    job_id: str
    idempotency_key: str
    created_at: int
    instruction: str
    allowed_paths: tuple[str, ...]
    inputs: Mapping[str, str]  # path -> sha256
    attempt: int

    def digest(self) -> str:
        body = asdict(self)
        body["allowed_paths"] = list(self.allowed_paths)
        body["inputs"] = dict(sorted(self.inputs.items()))
        return sha256_bytes(canonical(body))

    def write(self, queue_dir: Path) -> Path:
        payload = asdict(self)
        payload["allowed_paths"] = list(self.allowed_paths)
        payload["inputs"] = dict(self.inputs)
        payload["digest"] = self.digest()
        path = queue_dir / f"{self.idempotency_key}.json"
        if path.exists():
            old = json.loads(path.read_text())
            if old.get("digest") != payload["digest"]:
                raise ValueError("idempotency key reused with a different job")
            return path
        path.write_text(json.dumps(payload, indent=2))
        return path


def mint_job(repo: Path, instruction: str, allowed: list[str]) -> GenerationJob:
    inputs = {}
    for rel in allowed:
        p = repo / rel
        if not p.is_file():
            raise FileNotFoundError(rel)
        inputs[rel] = sha256_file(p)
    key = sha256_bytes(canonical({
        "instruction": instruction,
        "inputs": inputs,
    }))[:32]
    return GenerationJob(
        schema=SCHEMA,
        job_id=str(uuid.uuid4()),
        idempotency_key=key,
        created_at=int(time.time()),
        instruction=instruction,
        allowed_paths=tuple(allowed),
        inputs=inputs,
        attempt=1,
    )
Enter fullscreen mode Exit fullscreen mode

The idempotency key is not a random souvenir. It is a hash of instruction plus input digests. Same work order, same key.

If you change one file, the key changes. That is the point. You are not continuing a chat. You are submitting a new write.

Receipts beat transcripts

A receipt is a small JSON object. It names the job digest it claims to satisfy. It also names the output hashes.

# receipt.py
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from genjob import GenerationJob, sha256_file, SCHEMA

ALLOWED_STATUS = {"ok", "rejected", "aborted"}


@dataclass(frozen=True)
class Receipt:
    schema: str
    job_digest: str
    idempotency_key: str
    status: str
    outputs: dict[str, str]  # path -> sha256
    worker_id: str

    @classmethod
    def load(cls, path: Path) -> "Receipt":
        raw = json.loads(path.read_text())
        rec = cls(**raw)
        if rec.schema != SCHEMA:
            raise ValueError("receipt schema mismatch")
        if rec.status not in ALLOWED_STATUS:
            raise ValueError("unknown receipt status")
        return rec


def verify_receipt(job: GenerationJob, rec: Receipt, out_dir: Path) -> None:
    if rec.idempotency_key != job.idempotency_key:
        raise ValueError("receipt key does not match job")
    if rec.job_digest != job.digest():
        raise ValueError("receipt digest does not match job")
    if rec.status != "ok":
        raise ValueError(f"job not applyable: {rec.status}")
    extra = set(rec.outputs) - set(job.allowed_paths)
    if extra:
        raise ValueError(f"receipt wrote outside contract: {sorted(extra)}")
    for rel, digest in rec.outputs.items():
        got = sha256_file(out_dir / rel)
        if got != digest:
            raise ValueError(f"artifact drift at {rel}")
Enter fullscreen mode Exit fullscreen mode

Notice what the verifier does not read. It does not read the model name. It does not read the chat. It does not read the worker's opinions.

If the artifact drifted, you reject. If the path set grew, you reject. If the digest drifted, you reject.

Rejection is a first-class result. It is not an awkward silence in a thread.

Commands for a tiny control plane

Keep the queue on disk so you can see it. Hidden broker magic helps until it lies.

# proposed local flow, not a hosted product
mkdir -p .gen/queue .gen/running .gen/receipts .gen/out

python - <<'PY'
from pathlib import Path
from genjob import mint_job
job = mint_job(
    Path("."),
    "Add a timeout to fetch_user and keep the public signature.",
    ["src/user.py", "tests/test_user.py"],
)
print(job.write(Path(".gen/queue")))
print(job.idempotency_key, job.digest())
PY

# worker claims exactly one job
jobfile=$(ls .gen/queue | head -n 1)
mv ".gen/queue/$jobfile" ".gen/running/$jobfile"

# ship only allowed files; never the whole repo
python ship_bundle.py ".gen/running/$jobfile" /tmp/bundle.tgz

# remote generate happens here; local apply happens later
# scp /tmp/bundle.tgz free-server:
# ssh free-server 'python generate.py && python emit_receipt.py'

python - <<'PY'
from pathlib import Path
from genjob import GenerationJob
from receipt import Receipt, verify_receipt
import json
raw = json.loads(Path(".gen/running").glob("*.json").__iter__().__next__().read_text())
raw.pop("digest")
job = GenerationJob(**raw)
rec = Receipt.load(next(Path(".gen/receipts").glob("*.json")))
verify_receipt(job, rec, Path(".gen/out"))
print("receipt ok; apply may run")
PY
Enter fullscreen mode Exit fullscreen mode

The mv is the lock. One job file cannot live in two directories. If the worker dies after the move, you still have the job.

You can push it back to the queue. You can raise attempt in a new file with a new job_id. You keep the same idempotency key only when the document is identical.

That last rule saves you from ghost patches. Duplicate submits collapse. Changed submits become new jobs.

Failure domains, named on purpose

Name the planes or they will merge in a panic. The planner plane writes jobs and never applies. The worker plane talks to a generator and never holds production secrets.

The generator plane is rented, cheap, and forgetful. The receipt plane is a small store you can audit. The apply plane is the only plane that may write your tree.

Chat mixed those planes in one process. That is why a cancelled stream still felt like a half commit. Partial tokens are not a receipt.

If the free server vanishes, the job remains. You replay against another generator later. The contract did not live in that process.

If the apply host crashes after verify, you still have outputs. You run apply again with the same receipt. Apply must be idempotent too, or you built a new river.

A useful apply trick is content addressing. Write to .gen/out/<digest>/<path>. Then rename into the repo only after verify. Rename is closer to atomic than a streaming patch.

What this architecture refuses

It refuses session memory as a substitute for inputs. If the model needed a file, the job must list that file. Hidden context is an untracked dependency.

It refuses streaming apply. Tokens may stop at line twelve. A receipt either exists or it does not.

It refuses unbounded path sets. "Edit whatever you want" is not a work order. It is a blank check.

It also refuses to pretend free remote capacity is a production control plane. Scratch generate. Local verify. Local apply.

Who should not use this

Do not use this if you need a pair-programmer in a live buffer. The job shape is slower than chat for tiny edits. The extra files will annoy you.

Do not use this if your instruction embeds secrets. Replay stores the instruction. A queue directory is a leak waiting for a sync tool.

Do not use this on a shared branch with several apply hosts. One writer per path is a real constraint. Without a merge plane, two receipts will fight.

Do not use this as a substitute for tests. A receipt proves the worker returned the bytes it claimed. It does not prove the patch is correct.

Skip it for throwaway spikes in a personal sandbox. A crate factory for a ten-line script is ceremony. Save the ceremony for patches you might have to defend.

What I would change next

The next cut is a signed receipt, not a prettier prompt. The worker should sign the job digest and the output hashes. The apply host should verify that signature with a pinned key.

The cut after that is a real apply queue. Generate and apply still share too much airtime in this sketch. Apply should pull receipts, never push them into the tree from the worker.

I would also drop attempt into a child object. Retries are operational, not semantic. Mixing them into the job digest creates noise you will later regret.

None of that requires a smarter model. It requires a control plane that survives a killed SSH session.

You do not need a longer transcript. You need a job you can pick up tomorrow, hash again, and apply without guessing what the chat meant. If you try a disposable generator for the worker plane, keep the queue local, and let the receipt be the only handshake that may open the repo.

Top comments (1)

Collapse
 
brianainews profile image
Brian · AI News

Treating the model call as a keyed write is a clean mental shift. Receipts and replayable jobs should make duplicate submissions visible before they become production debt.