Skip to content

refactor(live): extract the shared JSONL plumbing behind Outbox and AnswerOutbox #36

Open

Description

@Ruqii

What

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.

Activity

  1. Ruqii commented on Sep 22, 2026

    @Ruqii
    ContributorAuthor

    Correction (2026-09-22)

    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.

  2. 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions