Long-Horizon Jobs
Progress must never live only in the conversation. Context limits kill sessions;
SIGKILL kills processes. A job is durable only if a kill at ANY instant loses
zero items and duplicates zero work on resume.
The contract — three files per job
Every long-horizon job owns a job directory (convention: data/jobs/<job-name>/)
containing exactly:
| File |
Role |
Write discipline |
findings.jsonl |
Append-only results. THE source of truth (the WAL). |
One JSON line per completed item, fsync after every append, written BEFORE the item is marked complete. |
state.json |
Cursor + completed-item hashes + error queue + stats. |
Atomic replace (tmp + fsync + os.replace) on every checkpoint. |
SUMMARY.md |
Rolling digest: counts, cursor, error tail, last 20 findings. |
Regenerated atomically at each checkpoint. The ONLY file an agent re-reads on resume. |
Recovery invariant: on load, the completed set is rebuilt as the UNION of
state.json.completed and the _hash fields found in findings.jsonl. A kill
between the WAL append and the state write therefore cannot duplicate (the hash
is recovered from the WAL) and cannot lose (the finding is already on disk).
A torn final JSONL line from a mid-append kill is skipped, and its item re-runs.
Implementation
Python: scripts/lib/checkpoint.py (CheckpointedJob). Contract tests:
scripts/tests/test_checkpoint.py (includes a 3x SIGKILL harness).
from checkpoint import CheckpointedJob
job = CheckpointedJob("data/jobs/sydecar-docs", "sydecar-docs")
for item in list_items(start=job.cursor): # cursor bounds re-listing
if job.is_done(item.id): # hash-set skip, O(1)
continue
try:
result = process(item) # the expensive part
job.record_finding(item.id, result) # WAL append -> mark done -> checkpoint
except Exception as e:
job.record_error(item.id, str(e)) # error queue, attempts counted
job.set_cursor({"last": item.id})
job.finish()
Rules:
- Item keys must be stable across runs (URL, document id, post id) — never
list indices or timestamps. The key is hashed (
item_hash) for the completed set.
- Do the expensive work between
is_done and record_finding, nothing after.
Side effects (file downloads) go to a deterministic path derived from the item
key, written atomically (tmp + rename), so a re-run overwrites rather than duplicates.
- Errors don't block progress.
record_error queues the item and the loop
continues; a later successful record_finding clears it. Retry the error queue
at the start of the next run, bounded by attempts.
- Never buffer batches in memory. One item = one durable write. If an API
forces batching, keep the batch small and checkpoint per batch.
- JS jobs follow the same file contract:
fs.appendFileSync + fsync for the
WAL, write-tmp-then-fs.renameSync for state/summary.
Agent resume protocol
When an agent (re)starts a long-horizon job:
- Read
SUMMARY.md ONLY. Do not re-read findings.jsonl, raw transcripts,
task outputs, or prior conversation. The summary carries counts, cursor, and
the error tail — everything needed to decide what to do next.
- Launch/continue the worker process. The library skips completed items itself.
- On context pressure or phase end, ensure the last checkpoint happened, then
summarize position in one sentence. The next session starts at step 1.
The PostToolUse hook (~/.claude/hooks/lh-observer-digest.sh, registered in
~/.claude/settings.json) injects a checkpoint reminder every 10 tool calls —
when it fires mid-job, flush state before continuing.
Memory observer
The memory-observer agent (~/.claude/agents/memory-observer.md) distills
durable observations at phase boundaries. It reads ONLY the pre-truncated event
digest at ~/.claude/observer/events/<session_id>.jsonl (each event capped at
400 chars by the hook; observer reads tail -c 24000, 6k tokens hard budget),
emits at most 10 observations per batch, and appends them to
`/.claude/observer/observations.jsonl` BEFORE replying. Never feed it raw
transcripts; never raise its budget — bounded-and-lossy beats complete-and-dead.
Anti-patterns (each one has already cost us)
- Progress tracked as a number in the conversation ("done 340 of 900") — the KB
Phase-1 backfill's escalating
--limit values were the only record of progress.
- Re-listing and re-diffing the whole corpus every run instead of a cursor
(KB ingest re-reads ~4,900 docs hourly; crash = full re-fetch).
- Buffering expensive results in RAM until a batch write (KB ingest's in-flight
200-doc batch discards up to 200 paid LLM calls on crash).
- Feeding an observer/summarizer untruncated tool output (claude-mem: unbounded
JSON.stringify + never-compacted history = "Prompt is too long" on every
batch, zero records captured).
- Rewriting a results file wholesale instead of appending (torn-write risk;
newsfeed
posts.json uses plain writeFile).
- Kill-testing skipped. A harvester is not done until it has been SIGKILLed
mid-run at least 3 times and resumed with zero duplicates and zero losses
(see
test_sigkill_mid_run_resumes_exactly_once).
1---2name: long-horizon-jobs3description: Durability contract for long-running agent jobs (harvesters, scrapers, ingest, audits). Use when building or running any job that processes many items over a long horizon, when a job must survive kills/context limits, or when resuming a previously interrupted harvester. Covers state.json + findings.jsonl + SUMMARY.md checkpointing, the memory-observer, and the 10-tool-call checkpoint hook.4---56# Long-Horizon Jobs78Progress must never live only in the conversation. Context limits kill sessions;9SIGKILL kills processes. A job is durable only if a kill at ANY instant loses10zero items and duplicates zero work on resume.1112## The contract — three files per job1314Every long-horizon job owns a job directory (convention: `data/jobs/<job-name>/`)15containing exactly:1617| File | Role | Write discipline |18|---|---|---|19| `findings.jsonl` | Append-only results. THE source of truth (the WAL). | One JSON line per completed item, `fsync` after every append, written BEFORE the item is marked complete. |20| `state.json` | Cursor + completed-item hashes + error queue + stats. | Atomic replace (tmp + fsync + `os.replace`) on every checkpoint. |21| `SUMMARY.md` | Rolling digest: counts, cursor, error tail, last 20 findings. | Regenerated atomically at each checkpoint. **The ONLY file an agent re-reads on resume.** |2223Recovery invariant: on load, the completed set is rebuilt as the UNION of24`state.json.completed` and the `_hash` fields found in `findings.jsonl`. A kill25between the WAL append and the state write therefore cannot duplicate (the hash26is recovered from the WAL) and cannot lose (the finding is already on disk).27A torn final JSONL line from a mid-append kill is skipped, and its item re-runs.2829## Implementation3031Python: `scripts/lib/checkpoint.py` (`CheckpointedJob`). Contract tests:32`scripts/tests/test_checkpoint.py` (includes a 3x SIGKILL harness).3334```python35from checkpoint import CheckpointedJob3637job = CheckpointedJob("data/jobs/sydecar-docs", "sydecar-docs")38for item in list_items(start=job.cursor): # cursor bounds re-listing39 if job.is_done(item.id): # hash-set skip, O(1)40 continue41 try:42 result = process(item) # the expensive part43 job.record_finding(item.id, result) # WAL append -> mark done -> checkpoint44 except Exception as e:45 job.record_error(item.id, str(e)) # error queue, attempts counted46 job.set_cursor({"last": item.id})47job.finish()48```4950Rules:51521. **Item keys must be stable across runs** (URL, document id, post id) — never53 list indices or timestamps. The key is hashed (`item_hash`) for the completed set.542. **Do the expensive work between `is_done` and `record_finding`, nothing after.**55 Side effects (file downloads) go to a deterministic path derived from the item56 key, written atomically (tmp + rename), so a re-run overwrites rather than duplicates.573. **Errors don't block progress.** `record_error` queues the item and the loop58 continues; a later successful `record_finding` clears it. Retry the error queue59 at the start of the next run, bounded by `attempts`.604. **Never buffer batches in memory.** One item = one durable write. If an API61 forces batching, keep the batch small and checkpoint per batch.625. **JS jobs** follow the same file contract: `fs.appendFileSync` + `fsync` for the63 WAL, write-tmp-then-`fs.renameSync` for state/summary.6465## Agent resume protocol6667When an agent (re)starts a long-horizon job:68691. Read `SUMMARY.md` ONLY. Do not re-read `findings.jsonl`, raw transcripts,70 task outputs, or prior conversation. The summary carries counts, cursor, and71 the error tail — everything needed to decide what to do next.722. Launch/continue the worker process. The library skips completed items itself.733. On context pressure or phase end, ensure the last checkpoint happened, then74 summarize position in one sentence. The next session starts at step 1.7576The PostToolUse hook (`~/.claude/hooks/lh-observer-digest.sh`, registered in77`~/.claude/settings.json`) injects a checkpoint reminder every 10 tool calls —78when it fires mid-job, flush state before continuing.7980## Memory observer8182The `memory-observer` agent (`~/.claude/agents/memory-observer.md`) distills83durable observations at phase boundaries. It reads ONLY the pre-truncated event84digest at `~/.claude/observer/events/<session_id>.jsonl` (each event capped at85400 chars by the hook; observer reads `tail -c 24000`, ~6k tokens hard budget),86emits at most 10 observations per batch, and appends them to87`~/.claude/observer/observations.jsonl` BEFORE replying. Never feed it raw88transcripts; never raise its budget — bounded-and-lossy beats complete-and-dead.8990## Anti-patterns (each one has already cost us)9192- Progress tracked as a number in the conversation ("done 340 of 900") — the KB93 Phase-1 backfill's escalating `--limit` values were the only record of progress.94- Re-listing and re-diffing the whole corpus every run instead of a cursor95 (KB ingest re-reads ~4,900 docs hourly; crash = full re-fetch).96- Buffering expensive results in RAM until a batch write (KB ingest's in-flight97 200-doc batch discards up to 200 paid LLM calls on crash).98- Feeding an observer/summarizer untruncated tool output (claude-mem: unbounded99 `JSON.stringify` + never-compacted history = "Prompt is too long" on every100 batch, zero records captured).101- Rewriting a results file wholesale instead of appending (torn-write risk;102 newsfeed `posts.json` uses plain `writeFile`).103- Kill-testing skipped. A harvester is not done until it has been SIGKILLed104 mid-run at least 3 times and resumed with zero duplicates and zero losses105 (see `test_sigkill_mid_run_resumes_exactly_once`).