You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
refactor(live): extract the shared JSONL plumbing behind Outbox and AnswerOutbox #36
live/ has two independent queue-and-retry implementations. The progress path was
deliberately unified; the answers path, added later, was not folded in.
Progress path (unified):tracker.py and sync.py both go through Outbox
(outbox.py:49) and Delivery (delivery.py). delivery.py's own module docstring
records why that unification was necessary:
They used to differ in a way that mattered -- the sender opened the session once at
start and then only posted events, and tp sync never opened it at all -- so a run
that began offline could never be recovered.
Answers path (not unified):answers.py:214 defines a second, separate AnswerOutbox with its own persistence and settling. grading.py imports only tp_runtime from delivery.py and implements its own _schedule_retry. Backoff logic
now exists in three places: client.py, tracker.py, grading.py.
Why it matters
The failure mode delivery.py was written to prevent is structurally available again on
the answers side, and sync.py (609 lines) spends a large share of itself stitching two
queues that could be one. #35 had to reason about the answers queue's stop semantics in
isolation, without the progress queue's machinery to lean on.
Scope
Fold AnswerOutbox into Outbox (or extract the shared part), keeping the
answer-specific settling — per-case receipts, RUN_SETTLED, digest checks — as a
layer on top rather than a parallel implementation.
Collapse the three backoff implementations into one.
sync.py's "two queues, one command" then becomes composition over one mechanism.
Non-goals
No change to the wire protocol, to report.json, or to tp sync's exit codes —
this is internal consolidation only (CLAUDE.md constraint 4).
Context
Found during an architecture review. Related: the CLI's live/ subsystem is 3,210 LOC
(33% of src/), larger than the entire pipeline CLAUDE.md declares as the architecture
(loader → runner → judge → reporter, ~1,694 LOC) — CLAUDE.md does not mention live/
at all and is worth updating alongside this.
The original framing below overclaimed on two counts. Re-read the code; the accurate
finding is narrower.
1. Outbox and AnswerOutbox are not the same data structure.Outbox is a
sequence-allocating append log — the file is the counter, events are immutable, and the
server acks the highest contiguous client_seq. AnswerOutbox is a last-write-wins state
log keyed by case_id — latest() folds records, queue() refuses a second record for a
key, settle() appends a new state for an existing one. Folding one into the other would
produce a parameterised class worse than both. Their semantics are correctly different.
What is duplicated is only the JSONL durability plumbing, ~40 lines: prepare()
(mkdir + touch + wrap OSError), _append() (dumps + open("a") + write + flush + wrap),
and _iter_records() (read_text, skip blank, model_validate_json, swallow a truncated
final line) — plus two near-identical error types (OutboxError, AnswerOutboxError).
2. "Backoff in three places" is wrong — it is two, with different jobs.client.py's _retry_after() only parses the HTTP header; it is not a backoff. tracker.py's _schedule_retry(now) paces session-open and ignores Retry-After; grading.py's _schedule_retry(now, error) paces answer delivery and lets Retry-After win. Sharing
them means the union of both behaviours, which neither caller wants today.
Revised scope
Small and optional: extract a JsonlLog base (or module function trio) holding prepare / _append / _iter_records over a pydantic row type, with one error type. Outbox and AnswerOutbox keep their own semantics on top. No change to the wire
protocol, to report.json, or to tp sync's exit codes.
The backoff halves should stay separate unless a third caller appears.
What stands from the original report
delivery.py's module docstring records that the progress path was deliberately unified
after tp sync and the in-run sender drifted apart in a way that lost runs. The answers
path never joined that unification — grading.py imports only tp_runtime from delivery.py — so the identity-verify → ensure-session → send ordering that delivery.py guarantees is not guaranteed for answers by the same code. That is worth a
look on its own, and is a different question from the outbox classes.
Context that stands
live/ is 3,210 LOC (33% of src/), larger than the entire pipeline CLAUDE.md declares
as the architecture (loader → runner → judge → reporter, ~1,694 LOC); CLAUDE.md does not
mention live/ or shapes/ at all. Updating CLAUDE.md is worth doing regardless of
whether any of the above happens.
changed the title [-]refactor(live): fold AnswerOutbox into Outbox — two parallel queue/retry implementations[/-][+]refactor(live): extract the shared JSONL plumbing behind Outbox and AnswerOutbox[/+]on Sep 22, 2026
Activity
Ruqii commentedon Sep 22, 2026
Correction (2026-09-22)
The original framing below overclaimed on two counts. Re-read the code; the accurate
finding is narrower.
1.
OutboxandAnswerOutboxare not the same data structure.Outboxis asequence-allocating append log — the file is the counter, events are immutable, and the
server acks the highest contiguous
client_seq.AnswerOutboxis a last-write-wins statelog keyed by
case_id—latest()folds records,queue()refuses a second record for akey,
settle()appends a new state for an existing one. Folding one into the other wouldproduce a parameterised class worse than both. Their semantics are correctly different.
What is duplicated is only the JSONL durability plumbing, ~40 lines:
prepare()(mkdir + touch + wrap
OSError),_append()(dumps + open("a") + write + flush + wrap),and
_iter_records()(read_text, skip blank,model_validate_json, swallow a truncatedfinal line) — plus two near-identical error types (
OutboxError,AnswerOutboxError).2. "Backoff in three places" is wrong — it is two, with different jobs.
client.py's_retry_after()only parses the HTTP header; it is not a backoff.tracker.py's_schedule_retry(now)paces session-open and ignoresRetry-After;grading.py's_schedule_retry(now, error)paces answer delivery and letsRetry-Afterwin. Sharingthem means the union of both behaviours, which neither caller wants today.
Revised scope
Small and optional: extract a
JsonlLogbase (or module function trio) holdingprepare/_append/_iter_recordsover a pydantic row type, with one error type.OutboxandAnswerOutboxkeep their own semantics on top. No change to the wireprotocol, to
report.json, or totp sync's exit codes.The backoff halves should stay separate unless a third caller appears.
What stands from the original report
delivery.py's module docstring records that the progress path was deliberately unifiedafter
tp syncand the in-run sender drifted apart in a way that lost runs. The answerspath never joined that unification —
grading.pyimports onlytp_runtimefromdelivery.py— so the identity-verify → ensure-session → send ordering thatdelivery.pyguarantees is not guaranteed for answers by the same code. That is worth alook on its own, and is a different question from the outbox classes.
Context that stands
live/is 3,210 LOC (33% ofsrc/), larger than the entire pipeline CLAUDE.md declaresas the architecture (
loader → runner → judge → reporter, ~1,694 LOC); CLAUDE.md does notmention
live/orshapes/at all. Updating CLAUDE.md is worth doing regardless ofwhether any of the above happens.
[-]refactor(live): fold AnswerOutbox into Outbox — two parallel queue/retry implementations[/-][+]refactor(live): extract the shared JSONL plumbing behind Outbox and AnswerOutbox[/+]