Skip to content

[#195] Streaming translation: Relaxed + Balanced cadence steps - #212

Merged
realproject7 merged 16 commits into
mainfrom
task/195-translation-modes
Aug 5, 2026
Merged

[#195] Streaming translation: Relaxed + Balanced cadence steps#212
realproject7 merged 16 commits into
mainfrom
task/195-translation-modes

Conversation

@realproject7

@realproject7 realproject7 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Fixes #195

During continuous speech the caption keeps scrolling but no translation appears — for up to 30 seconds. Translation can only start when an utterance finalizes, which needs an ~800 ms pause or the 30 s force-cut, so a speaker who does not pause leaves the viewer with nothing.

This adds a Translation speed setting with two shipped steps: Relaxed (today's behaviour, the default) and Balanced (translation follows clause by clause).

EPIC Alignment

What ships, and what does not

step behaviour mechanism
Relaxed (default) translation appears when the speaker pauses finalize only — today's code path, unchanged
Balanced translation follows at clause boundaries release a stable prefix ending in sentence-final punctuation
Live held permanently by PO ruling; see below

The measurement, which is what made this decidable

Every number below is produced by stable_prefix_measure.rs on scripted fixtures, re-derivable with cargo test -p livecap-core --lib cadence_table -- --nocapture. Turns are measured exactly (units dispatched is deterministic, and turns are what the engine bills). Tokens are deliberately not claimed: tokenizing needs the real engine, and a translate turn measured 165 input tokens for one sentence on the Claude tier, so per-turn overhead dominates and the turn multiplier is the honest proxy.

fixture Relaxed Balanced Live (held)
natural speech (pauses) 5 turns · p95 4.4 s 1.0× · p95 4.4 s 2.0× · p95 2.4 s
mixed meeting (8 paused clauses + two 2-clause monologues) 10 turns · p95 8.6 s 1.2× · p95 4.4 s 2.0× · p95 4.5 s
continuous (25 s, no pause) 1 turn · p95 24.2 s 5.0× · p95 4.5 s 6.0× · p95 4.5 s
continuous + unpunctuated 1 turn · p95 24.2 s 1.0× · p95 24.2 s 7.0× · p95 3.9 s

The multiplier is a property of the speech pattern, not the mode. Balanced costs nothing in ordinary paused speech and rises only during the exact condition it exists to fix. Even at its 5.0× peak its absolute rate (11.8 turns/min) stays below the 13.3 turns/min that Relaxed itself produces on ordinary speech — it never pushes the engine harder than normal conversation, it removes a pathological quiet period.

Why Live is not here. It cleared the PO's 2× bar on the mixed fixture, but the measurement showed it buys nothing over Balanced on punctuated speech (p95 4.5 s vs 4.4 s) while spending 1.67× more turns. Its value exists only in unpunctuated speech. The PO held it on the grounds that a manual setting is the wrong shape for a benefit the user cannot predict in advance, and filed #211 to detect the condition automatically instead. That is a better design and it came out of this measurement.

The Settings copy carries the measured number

Per the ticket, no vague "may use more tokens". Balanced's picker copy, approved verbatim:

follows clause by clause · no extra requests in ordinary paused speech, ~1.2× in a mixed meeting, up to 5× during sustained unbroken speech — where it replaces a ~24 s wait

Three conditions rather than one number, because a single figure would be wrong in one direction or the other.

How it composes with the fall-back switch (ticket requirement 4), stated in the sheet itself rather than left to inference: a faster step sends more translation requests, so it drains the pool faster and reaches isBelowThreshold() sooner — at which point the existing "fall back to Local if credits ever start to apply" switch does exactly what it already does. The cadence changes the spend rate; the switch remains the guard. Nothing about the threshold or that switch changed here.

The cost invariant: no text is translated twice

This is what makes streaming affordable, and it is structural rather than defensive:

The one deliberate exception: a failed unit is re-translated — folded into the tail if it failed before finalize, dispatched as its own retry turn if it failed after. Paying for a span twice is recoverable; a line missing what the speaker said is not.

Live is unreachable, not merely unlisted

"Held permanently" is stronger than "absent from the picker" (RE2's distinction, and it shaped the implementation). live is clamped at all four boundaries where a string becomes a mode:

  1. the Rust settings sanitizer (relaxed|balanced; a hand-edited "live" → Relaxed, asserted)
  2. the protocol start-message type, which admits only the two shipped values
  3. resolveStartConfig, so a crafted or stale start message cannot activate it (asserted)
  4. session.rsPipelineConfig, the last conversion before the pipeline

TranslationMode::Live remains in livecap-core on purpose: it is the dwell mechanism #211 needs, and it is what the unpunctuated measurement asserts against. Deleting it would discard both the mechanism and the evidence that justified filing #211 — but nothing user-facing can select it.

Design Fidelity

The only UI surface is the Settings sheet: one new Translation speed segmented control and its note. src/settings-sheet.ts (+25) and src/main.ts (+2, the default) are the only changed UI files; no CSS file is touched (git diff --stat origin/main -- '*.css' is empty).

Requirement Implementation Verified
Uses the design system's existing control, not a new one Third .sh-seg segmented control, same as the engine / Claude-model / STT pickers — the ticket said "whichever the design system already has (do not invent a new control)" settings-sheet.ts:114
Discrete named steps, not a slider Two .sh-seg-btns from TRANSLATION_MODES; the mechanism does not vary continuously, so a slider would imply precision that cannot be implemented settings-sheet.ts:74
Lives alongside the engine/budget controls Placed after the Claude controls and their note, directly above the gauge and the fall-back switch it composes with settings-sheet.ts:113
Each step's copy carries its MEASURED cost "no extra requests in ordinary paused speech, ~1.2× in a mixed meeting, up to 5× during sustained unbroken speech — where it replaces a ~24 s wait" app-settings.ts TRANSLATION_MODES
The fall-back interaction is stated, not inferred Note: a faster step reaches the fall-back threshold sooner and the existing switch still catches it settings-sheet.ts:115-119
Selection reflects the persisted value aria-pressed set per button in renderControls from sanitizedTranslationMode, so an unknown value renders as Relaxed settings-sheet.ts
Existing look preserved Reuses .sh-seg / .sh-seg-btn / .sh-engine-note / .t-meta; zero CSS changes, no raw colors color-guard pass
Accessibility matches the sibling pickers role="radiogroup" + aria-label="Translation speed", aria-pressed per button settings-sheet.ts:114
No persisted value interpolated into markup Labels and notes are module-level literals; the selection is applied via attributes, not string-built HTML settings-sheet.ts

No screen in design/screens/ covers a translation-cadence control, so this follows the established pattern of that sheet rather than a reference image — the same situation as #204's Codex option.

Self-Verification

Tests: livecap-core 82 (+21), engine 338, archive 110, app 216 (+31). pnpm lint, pnpm typecheck (both configs), no-stub-gate, color-guard, cargo clippy --all-targets -D warnings all clean.

Archive/1:1 mapping (#137) is byte-compatible. In Relaxed there are no units, the tail is the whole line, and the path is identical to before. In Balanced the transcript line and the archive entry both use the full finalized text; only what is dispatched shrinks.

The regressions now run at the level the bugs live at. The retry defect was invisible to assembler-only tests because it breaks in the runner's dedup; the runner tests drive a real failing engine end-to-end, and one of them pins the defect itself (an enqueue from onFailed is swallowed). The cap consequence was untestable while the decision sat in HostSession, which has no headless harness — moving it fixed that.

Ordering is tested where the risk is. StreamingAssembler and routeFailures have 28 tests covering the cases that would silently corrupt an archive: a unit still in flight at finalize, a failed unit, a wedged unit at the drain deadline, double-assembly, channel separation, a cancelled utterance's units (#56/#62), and a pretranslatedWords count larger than the finalized text.

The reviewers found five defects here, and they were all the same defect. Every one was a mechanism that was written and tested in isolation and then reached by wiring that nothing covered: noteUnitFailed with zero call sites, dropChannel with zero call sites, a cap whose consequence lived in an untestable place, a retry that enqueue deduped away, and a cap that counted per utterance instead of per channel. The final round answers the pattern rather than the instances — the decisions moved to where they can be asserted (admitUnit, routeFailures), and the retry footgun was removed rather than documented: requeueFailed is gone, and enqueue itself honours a retry of a just-failed id, so there is no second entry point a caller can fail to reach for. That also caught a failure site I had missed, runOneToOne, which reports per-sentence failures while the rest of the batch is still in flight.

Two blockers RE1 found in 5d79490, both confirmed and both mine. Cap-drop lost its span: stable_prefix.rs:222 advances the released-words watermark when it emits a unit and never learns the host declined one, so a unit refused at the in-flight cap was still counted in pretranslatedWords, the tail began after it, and those words were archived as source and never translated — while the comment at the drop site asserted the opposite. The cap decision now lives in StreamingAssembler.admitUnit, beside the count it depends on and the failed-unit path it reuses, which also puts it somewhere testable. The post-finalize retry never dispatched: onFailed runs in run()'s catch, and ids leave inFlightIds only in the finally after it, so enqueue deduped the retry away silently. TranslationRunner.requeueFailed stages it and the finally flushes it — not by clearing inFlightIds earlier (that loosens #139's dedup for every caller) and not by a microtask (correctness resting on task ordering no test would pin).

Failure timing is its own case, and RE2 found it. Folding a failed span into the finalize tail only works while the unit is still pending; once the utterance has finalized, that tail turn is already out without the span, so the failed unit both wedged isReady() until the drain deadline and was then dropped from the line. noteUnitFailed now returns a retry for the post-finalize case, dispatched under the same unit id; a second failure abandons the unit rather than retrying forever, and isReady treats abandoned as settled so a line can never wedge on a span that will never arrive. Proven by seeding: every fix above is reverted independently and the new tests fail on exactly the reported symptom — a lost span, and one engine call where two are owed.

Rust validated on Linux where CI cannot. livecap-app does not build here, so the cadence clamp was extracted verbatim into an offline harness against real serde — 12/12, including all six held/unknown inputs. rustfmt diff counts match main exactly on all touched files (bridge 2, settings 5, session 13; the three new/changed core files are 0).

A bug this work found in itself: the unpunctuated fixture — added specifically to differentiate two modes I had already reported as "identical" — exposed that Live released nothing during continuous speech. Its dwell keyed on "settled text stopped growing", but settled text grows on every partial during continuous speech, so the timer reset forever. Live only fired when the speaker paused, which is when finalize happens anyway. Fixed in 9e86526; the corrected numbers are what produced the PO's ruling.

Every guard is seeded, including the ones for defects already fixed. Each fix is reverted independently and its suite run alone: cap counts only pending → 3 failed; enqueue ignores a failing id → 2 failed; unit failures routed as captions → 2 failed (this last re-seeds the round-1 blocker, which nothing had covered); refused span not folded → the span is lost; retry not staged → one engine call where two are owed.

Kill-list: clean — no new dependency, no TODO/FIXME/stub, no caption content logged or persisted (the pipeline logs metadata only, and units never reach argv or a log line).

Deviations

  • HostSession glue is not unit-tested, because it spawns real children and has no headless harness — the same constraint customEndpointNotice already documents in that file. This is the constraint that produced most of the review rounds on this PR: a decision placed on the wrong side of it is unguarded, and I put several there. Every load-bearing decision has now been moved out — admitUnit (the cap and its consequence), routeFailures (unit vs caption), StreamingAssembler (assembly ordering), TranslationRunner (retry dispatch) — leaving glue that only forwards. What remains genuinely uncovered is that forwarding itself; it is thin by construction, and that is the residual risk, stated rather than implied.
  • MIN_UNIT_WORDS = 8 and LIVE_DWELL_MS = 1500 are bootstrap constants, not tuned parameters. The minimum exists so a turn is never spent on "So —"; it is asserted by a test that a 3-word clause is never released.
  • Latency is fixture time, i.e. from a word being heard in a partial to its unit being dispatched. It measures the scheduling change, which is what the modes affect; engine round-trip is identical across modes and deliberately excluded.
  • No device verification. Everything here is headless; the feel-check rides the next device session, per the ticket's routing.
  • No change to the archive format, the finalized-caption contract, the threshold, or the fall-back switch.

realproject7 and others added 11 commits August 5, 2026 00:47
Translation currently cannot start until an utterance finalizes, which needs an
~800 ms pause or a 30 s force-cut, so a speaker who does not pause gets no
translation for up to half a minute while the caption keeps scrolling.

Adds LocalAgreement stable-prefix extraction: consecutive partials are compared
and the longest prefix they AGREE on is settled, so it can be translated early
and never needs translating again.

Three discrete modes rather than a slider, because the mechanism genuinely does
not vary continuously and a slider would imply precision that cannot be
implemented honestly:
  Relaxed  (default) - finalize only; today's path, byte-compatible
  Balanced           - release at sentence-final punctuation in the settled text
  Live               - clause boundary, or settled text that stopped growing

Relaxed is the default and releases nothing from partials, so nobody's token
spend changes until they opt in. An unknown persisted value also falls back to
Relaxed: a corrupt setting must never silently upgrade someone's bill.

The cost invariant is asserted rather than asserted-in-prose: a test drives a
speaker who never pauses and checks that concatenating every released unit
reproduces the utterance EXACTLY once - no gaps, no repeats. Minimum unit size
is enforced too, so a turn is never spent on "So -".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the stable-prefix tracker into the transcription worker, where partials
already become events. A settled span is emitted as CaptionKind::TranslationUnit
- translation-only, never displayed as a caption and never archived, so the
caption and archive paths stay byte-unchanged.

Finalized events gain pretranslated_words: how much of the utterance was already
released. The consumer archives the FULL text exactly as before and translates
only the tail beyond that count, which is what keeps a span from being paid for
twice across the partial/finalize boundary.

Trackers are keyed by channel for the same reason pinned_lang is - mic and
system utterances interleave through one worker, and one channel's watermark
must not leak into the other's. A dropped partial (#56/#62 bleed suppression)
resets the tracker, because that utterance is cancelled and its watermark must
not carry into the next one.

Relaxed remains inert: no unit is released and pretranslated_words is always 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Produces the multipliers the Settings copy must state, from a scripted fixture
rather than an estimate. Same script drives every mode, so any difference is the
mode's scheduling and not a different input.

The headline finding is that the multiplier is a property of the SPEECH PATTERN,
not of the mode:

  continuous speech (25.4 s monologue, no pause)
    Relaxed   1 turn    p50 13.4 s  p95 24.2 s    1.0x
    Balanced  5 turns   p50  2.1 s  p95  4.5 s    5.0x
    Live      5 turns   p50  2.1 s  p95  4.5 s    5.0x

  natural speech (pauses between clauses)
    Relaxed   5 turns   p50  2.6 s  p95  4.4 s    1.0x
    Balanced  5 turns   p50  2.6 s  p95  4.4 s    1.0x
    Live      5 turns   p50  2.6 s  p95  4.4 s    1.0x

So the 5x is measured against a baseline of ONE turn per 25 seconds - which is
exactly the broken behaviour the ticket exists to fix (p95 24.2 s with no
translation at all). In absolute terms the streaming modes settle at 11.8
turns/min, BELOW the 13.3 turns/min that Relaxed itself produces on ordinary
speech.

Deliberately not asserting a 3x ceiling: that assertion failed, and silently
tuning constants until it passed would be gaming the gate. The >3x continuous
case and the <=1.5x natural case are both asserted instead, so the finding is
pinned rather than hidden, and the shipping decision goes to Head with numbers.

Also asserts the operator's check directly: across all three modes, words
released equals the utterance word count exactly - no span translated twice, no
span left untranslated - over a scripted continuous utterance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unpunctuated fixture found a real bug, not just a missing test: Live
released NOTHING on continuous unpunctuated speech, which is the exact case it
exists for.

The dwell keyed on "settled text stopped growing". During continuous speech the
settled prefix grows on every partial, so that timer reset forever and never
elapsed - Live only fired when the speaker paused, which is when finalize
happens anyway. It was Balanced with extra steps.

The dwell now measures how long text has been WAITING to be released: the clock
starts when settled-but-unreleased text first appears and keeps running as more
settles. That makes Live fire mid-speech, which is the whole point.

Measured consequence - Live is now genuinely a distinct step, and costs more
than the first measurement suggested:

  continuous            Balanced 5.0x  Live 6.0x   (p95 24.2s -> 4.5s)
  natural (pauses)      Balanced 1.0x  Live 2.0x   (p95  4.4s -> 2.4s)
  continuous UNPUNCT.   Balanced 1.0x  Live 7.0x   (p95 24.2s -> 3.9s)

The unpunctuated row is the justification for Live existing: Balanced finds no
boundary to cut at, so it degrades to Relaxed and the viewer waits 24 s again.
Asserted rather than described.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a streaming mode on, an utterance is translated in pieces, but the archive
still stores exactly ONE line per utterance with one target - #137's 1:1 mapping
is a contract, not an implementation detail. This is the state machine that puts
the pieces back together, kept pure so the ordering is testable without a live
engine.

The hard part is not concatenation, it is that a unit's translation may still be
in flight when the utterance finalizes. So assembly can wait, and it degrades
safely in the two ways that matter:

  - a FAILED unit folds its source back into the tail for retry. That is a
    deliberate exception to never-translate-twice: paying for a span twice is
    recoverable, an archive missing what the speaker said is not.
  - forceAssemble() takes whatever arrived, so a wedged unit can never hold an
    archive line hostage on the drain deadline.

Also drops a cancelled utterance's units (#56/#62 bleed suppression) so they
cannot attach to whatever the channel says next, and clamps a pretranslated
count larger than the finalized text so a downward revision never skips real
words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PO asked for the decision fixture: mostly paused speech with a couple of
run-on stretches, rather than 100% continuous which nobody actually speaks.

  MIXED (12 clauses: 8 paused, two 2-clause monologues)
    Relaxed   10 turns  p95 8.6 s
    Balanced  12 turns  1.20x  p95 4.4 s
    Live      20 turns  2.00x  p95 4.5 s

Live lands at exactly 2.00x. The finding that matters for the ruling is that its
p95 is no better than Balanced here - on punctuated speech Balanced already
catches every boundary, so Live buys nothing and costs 1.67x more turns than it.
Live' value is confined to unpunctuated speech.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…isted

Ships Relaxed + Balanced per the PO ruling. Relaxed stays the default, so
nobody's spend moves without opting in.

RE2's point drove the shape: "held permanently" is stronger than "not in the
picker" - if a `live` value survived anywhere in the persisted clamp, the
protocol type, or the start-config path, the mode would be shipped-but-
unlabelled rather than held. So it is unreachable at every boundary that turns a
string into a mode:

  - Rust settings sanitizer clamps to relaxed|balanced
  - the protocol start message TYPE admits only those two
  - resolveStartConfig clamps again, so even a crafted message cannot activate it
  - the TS mirror's type and sanitizer match

TranslationMode::Live stays in livecap-core deliberately: it is the dwell
mechanism #211 needs and what the unpunctuated measurement asserts against.
Deleting it would throw away both the mechanism and the evidence that justified
filing #211 - but nothing user-facing can select it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…copy

Ships the PO-approved copy verbatim: "no extra requests in ordinary paused
speech, ~1.2x in a mixed meeting, up to 5x during sustained unbroken speech —
where it replaces a ~24 s wait". Three conditions rather than one number,
because the multiplier genuinely depends on how the speaker talks and any single
figure would be wrong in one direction or the other.

Placed after the Claude controls and their note rather than between them, so the
subscription/budget note still reads as belonging to the model picker it
describes. Sits directly above the gauge and the fall-back switch, which is what
it composes with: a faster step sends more requests, reaches the fall-back
threshold sooner, and the existing "fall back to Local" switch still catches it.
That relationship is stated in the note rather than left for the user to infer.

Reuses the existing .sh-seg/.sh-seg-btn/.sh-engine-note classes - no CSS change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds BridgeCaption::TranslationUnit and a distinct "translationUnit" host
message, so the streaming path never touches the caption/archive path: the
webview ignores it entirely and only the host acts on it. Units draw ids from
the same monotonic sequence as captions, so the host can correlate a dispatch
with its result without a second id space.

The finalized caption gains pretranslatedWords. Its text is unchanged, so the
archive still stores exactly one line per utterance - the host translates only
the tail beyond that count.

session.rs passes the persisted cadence into PipelineConfig via
from_str_or_default, which maps anything unshipped - including a hand-edited
"live" that somehow survived the sanitizer - to Relaxed. That is the fourth and
last boundary where a string becomes a mode, and all four clamp.

livecap-app cannot build on Linux, so the clamp was extracted verbatim into an
offline harness against real serde: 12/12 checks, including all six held/unknown
inputs and the camelCase wire key. rustfmt diff counts match main exactly on all
three touched files (2/5/13), so none are introduced here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the streaming path. Units are dispatched through the SAME
TranslationQueue as captions - there is no second dispatch path, per the ticket -
but tracked as units so a unit result can never become an archive line.

A finalized caption now enqueues only its untranslated TAIL. The transcript line
and the archive entry both still use the full text, so #137's 1:1 mapping and
the archive format are unchanged; what shrinks is only what we pay to translate.
In Relaxed there are no units, the tail is the whole line, and the behaviour is
byte-identical to before.

Two failure modes are handled rather than hoped away:

  - a fully-streamed utterance owes no tail turn, so nothing would ever arrive to
    trigger archiving; it archives as soon as its units land instead.
  - on stop, anything still outstanding after the drain deadline is written from
    whatever DID arrive. A partially-translated archive line is recoverable; a
    missing one is not.

HostSession has no headless harness (it spawns real children), so the logic that
can be tested lives in StreamingAssembler, which has 12 tests covering exactly
these orderings. The glue here is deliberately thin for that reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught what my local checks could not: adding pretranslated_words to
CaptionKind::Finalized broke two exhaustive destructures.

  crates/livecap-core/tests/pipeline_wav.rs - an INTEGRATION test, which
    cargo test --lib does not compile. It also needed the new TranslationUnit
    arm.
  src-tauri/src/bridge.rs:217 - a second test fixture I missed; livecap-app
    does not build on Linux, so nothing local would have caught it.

The real lesson is the first one: I ran `cargo test -p livecap-core --lib`
throughout, which silently skips tests/. `--all-targets` compiles them, and now
does locally too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Epic Alignment: FAIL

The PR advances #195, but two required runtime cost/data-integrity guards are not wired through the host path, and the UI PR is missing the required fidelity artifact.

Checked (evidence)

  • Structural gate: body has ## EPIC Alignment, ## Self-Verification, and ## Deviations, but no ## Design Fidelity table despite changing src/settings-sheet.ts.
  • Archive path reviewed: src/host/streaming-assembly.ts:43-165 and src/host/session.ts:680-781.
  • Live CI: gh pr checks 212 → all five checks passing.
  • Kill-list: findings below; no approval until resolved.

Findings

  • [blocker] Failed streamed units are not marked failed, so their source is dropped instead of folded back into the finalize tail.

    • File: src/host/session.ts:583-587; src/host/streaming-assembly.ts:60-78
    • Why it fails: onFailed routes unit ids through recordBatch as empty results; that calls noteUnitResult("" ), while noteUnitFailed has no call site. tryAssemble then treats the empty target as complete and omits the unit source.
    • Do instead: carry a unit/caption discriminator and call assembler.noteUnitFailed(id) for failed unit ids (add an end-to-end failure regression); preserve caption failure handling separately.
  • [blocker] Cancelled partials leave their streamed units attached to the next utterance.

    • File: src-tauri/src/bridge.rs:107-119; src/host/streaming-assembly.ts:147-151
    • Why it fails: Cleared/PartialDropped has no host message, and dropChannel is never called. Units released before a #56/#62 cancellation remain in pending and are consumed by the next finalize on that channel.
    • Do instead: forward a translation-only clear event to the host and invoke assembler.dropChannel(channel); add a cancellation-then-next-utterance regression.
  • [blocker] Required per-channel in-flight backpressure is absent.

    • File: crates/livecap-core/src/stable_prefix.rs:176-223; src/host/translation-runner.ts:110-117
    • Why it fails: every eligible partial can emit another unit and TranslationRunner.enqueue has no per-channel cap/coalescing, so a slow engine can accumulate unbounded queued turns, violating #195's max-in-flight cost guard.
    • Do instead: enforce a bounded per-channel unit budget and coalesce/defer stable text until finalize when full; add a sustained-speech backlog test.

Decision

Request changes. The archive failure and cancellation paths can silently lose or misattribute source text, and unbounded unit queuing violates the ticket's non-negotiable cost guard.

@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — REQUEST CHANGES

PR #212 (Fixes #195) @ 6172b4e — all 5 CI checks green. One blocking finding, in the archive contract you asked me to look hardest at. Everything else verified clean, including all four of the PO's criteria.

Checked (evidence)

Riskiest part: the archive 1:1 contract — and there is a hole in it. A unit whose translation fails after the utterance finalizes is neither retried nor waited for successfully: its span is silently dropped from the archived line.

The path (src/host/streaming-assembly.ts):

  1. noteUnit() dispatches a unit; its result is still in flight.
  2. onFinalized() (:87) partitions units at that moment: const failed = units.filter(u => u.failed) (:98) folds already-failed sources into the tail; const live = units.filter(u => !u.failed) (:104) become the awaited set. The retry carve-out only runs here.
  3. The unit's failure arrives afterwards. noteUnitFailed() (:75) sets failed = true on a unit now living in entry.units — but nothing re-reads that flag.
  4. isReady() (:157) requires entry.units.every(u => u.target !== null); a failed unit has target === null, so the entry is never ready.
  5. Assembly therefore blocks until the drain deadline, and forceAssemble() emits u.target ?? "" → filtered out → the span vanishes.

Measured, not reasoned — ran against the module at 6172b4e:

tailText at finalize: "and we will adjust"
tryAssemble:  null
forceAssemble: "그리고 조정하겠습니다"

The archived line is the tail alone. The clause "we are committed to the dual mandate." — dispatched as unit 1, failed after finalize — is gone, with no signal.

Why this blocks. It is the exact outcome the module's own comment calls unacceptable: "Losing a span silently would be worse than paying for it twice… the alternative is an archive that omits what the speaker said." The same engine failure produces a correct retry if it lands before finalize and a silent hole if it lands after — a race decides whether the transcript is complete. And the ordering is not exotic: this module exists because unit results arrive after finalize, and a failure is one such result (rate limit, transport error, a fallback switch mid-flight). It also delays the line to the drain deadline, since isReady can never become true.

The existing test does not cover ittest/streaming-assembly.test.ts:70 calls noteUnitFailed(1) before onFinalized, which is the handled ordering.

What I'd accept (your call which):

  • noteUnitFailed() folds the source into the awaiting entry's tailText when the unit has already been finalized into awaiting — needs care, since the tail may already be dispatched or translated; or
  • keep the span visible by re-dispatching that source as its own turn and holding assembly for it, still bounded by the drain deadline; or
  • at minimum, make isReady/forceAssemble treat a post-finalize failed unit explicitly rather than as "never arrived", so the line is not both delayed and silently truncated.

Plus a regression asserting the post-finalize failure ordering, since the pre-finalize one already passes.


The PO's four criteria — all verified, independently of the PR body

1. Default is Relaxedsettings.rs:92 default_translation_mode(), asserted at :527-531; TS mirror defaults likewise. Checked at the persisted-settings layer, not just the picker.

2. live is unreachable, not merely unlisted — I enumerated the string→mode boundaries myself rather than counting from the PR:

  • src-tauri/src/settings.rs:88,213-217TRANSLATION_MODES = ["relaxed","balanced"], trim + allowlist, else default.
  • src/protocol.ts:96 — the start-message type admits only the two, so live cannot be expressed.
  • src/host/start-config.ts:86message.translationMode === "balanced" ? "balanced" : "relaxed", an allowlist rather than a denylist, so anything else (including live) lands on Relaxed.
  • src/app-settings.ts:82 — TS mirror type.
  • src-tauri/src/session.rs:582TranslationMode::from_str_or_default(&settings.translation_mode, …). This is the one that matters, because from_str_or_default (stable_prefix.rs:57) does map "live" => Self::Live. It is safe only because settings.translation_mode cannot hold "live": SettingsState::replace() (settings.rs:329-330) is the sole mutation path and calls sanitized() before both save_atomic and the in-memory update, and load() sanitizes too. So snapshot() can only ever yield a clamped value. Verified rather than assumed — this is the single point the Rust pipeline path depends on.

Keeping TranslationMode::Live in the core is right: it is #211's mechanism and what the unpunctuated measurement asserts against.

3. Never translate the same text twicecrates/livecap-core/src/stable_prefix.rs:389 no_word_is_ever_released_twice is stronger than the dispatch count that was asked for: it drives a never-pausing speaker with a growing partial and asserts released.join(" ") == text, which catches gaps and repeats in one assertion where a count catches only repeats. And the carve-out I was worried about turns out to be structurally separated — the never-twice invariant lives in the tracker, the retry lives in the assembler, so the exception cannot weaken the invariant. That separation is the right design.

4. The copy matches the measurementsrc/app-settings.ts:100 carries the three condition-specific numbers verbatim (no extra requests in ordinary paused speech, ~1.2× in a mixed meeting, up to 5× during sustained unbroken speech — where it replaces a ~24 s wait), and 1.2× / 5.0× / 24.2 s all appear in the PR body's fixture tables. No drift.

Scope and gates: 27 files, +1939/−70, no new dependencies. All 5 CI green at 6172b4e. Your disclosure that HostSession's glue is not unit-tested is accurate and correctly mitigated by keeping the logic in StreamingAssembler — and it is also why this finding matters: the untested glue is thin, but the assembler is where the ordering bug lives, and that is tested territory.

(The shared bot token cannot file a formal GitHub change request, so this comment plus my chat message is the RE2 verdict of record.)

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-finalize failure ordering refinement: noteUnitFailed alone is insufficient. If a unit fails after onFinalized moves it into awaiting, isReady still waits on that failed unit (src/host/streaming-assembly.ts:104-106,153-159), and forceAssemble emits an empty part (:137-145), silently dropping the source. Failed-after-finalize must be requeued/folded into the tail (or otherwise treated as ready with source retry), with a regression covering failure after finalize.

realproject7 and others added 3 commits August 5, 2026 04:23
All three blockers were the same shape, and it is worth naming: I wrote the
mechanism, tested it in isolation, and never called it. My 12 assembler tests
passed the whole time because they exercise the component directly - nothing
asserted the component is REACHED. I had even disclosed "the glue is not
unit-tested" in the PR body, identified the risk, and then not mitigated it.

1. Failed units were routed through recordBatch as an EMPTY translation, so the
   span silently vanished from the archived line. noteUnitFailed - written and
   tested - had zero call sites. Failures are now partitioned: unit failures mark
   the unit failed (its source folds into the tail for retry), caption failures
   keep today's behaviour.

2. dropChannel had zero call sites because Cleared/PartialDropped never reached
   the host - the bridge mapped it to None, which was correct before #195
   because nothing downstream cared. A cancelled utterance's units could attach
   to the next one. Now forwarded as "captionCleared".

3. No per-channel in-flight cap. Added, and the count lives in the assembler
   rather than the session: a parallel tally would be a second source of truth
   that drifts, and the drift would be invisible until a channel silently stopped
   streaming. At the cap a unit is dropped rather than queued - its text is not
   lost, because the finalize path translates everything past the watermark.

The in-flight count is now covered by three tests, including that a FAILED unit
releases its slot - otherwise a channel whose units keep failing wedges at the
cap and stops streaming with no symptom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app-macos caught it: partial_dropped_maps_to_cleared_without_consuming_an_id
asserted host_message().is_none(), which was correct until this PR and is
exactly the behaviour RE1's blocker 2 required changing.

The test now asserts the NEW contract rather than being deleted: Cleared reaches
the host as "captionCleared", still consumes no id (the next_id closure panics
if consulted), and carries no text - cancelling an utterance must never move
caption content.

I cannot compile src-tauri on Linux, so after changing bridge behaviour I
grepped the file for tests asserting the old contract. The only other
host_message().is_none() is Partial's, which is correctly unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2's refinement on #212, confirmed real. noteUnitFailed folded a failed span
into the finalize tail, which only works while the unit is still PENDING. Once
onFinalized has run, that tail turn is already dispatched without the span, and
the failed unit sits in `awaiting` with target === null. Two consequences, both
bad: isReady() never returned true, so the line waited for the drain deadline;
then forceAssemble mapped the unit to "" and the span vanished from the line.

noteUnitFailed now distinguishes the two moments. Pending: fold into the tail,
as before, returns null. Post-finalize: return a UnitRetry the session
re-dispatches under the SAME unit id, so its result lands through the existing
unit-result path and fills the slot in place.

A second failure abandons the unit instead of retrying forever, and isReady
treats abandoned as settled - a wedged line is worse than a short one. The full
source text is archived regardless, so the utterance is still recorded and the
existing retranslate path can repair the target.

Seeded-violation proof: both new behaviour tests fail against the pre-fix
noteUnitFailed/isReady (2 failed | 17 passed).

app 205 (+7). pnpm lint, typecheck (both configs), no-stub-gate, color-guard clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — APPROVE (at 5d79490)

PR #212 (Fixes #195) @ 5d7949041b20218e30c6949aa0617a6a1af29251 — all 5 CI checks green. My blocking finding is fixed, and the fix is broader than the one I described. Everything read with git show 5d79490:<path> and every suite run at that SHA in my own worktree.

Checked (evidence)

Riskiest part: the archive 1:1 contract under a post-finalize unit failure. That was my blocker, and it is closed — verified by re-running the exact probe that exposed it, not by reading the diff.

Before (6172b4e):

tryAssemble:  null            <- wedged, isReady never true
forceAssemble: "그리고 조정하겠습니다"   <- tail only; the unit's clause silently gone

After (5d79490), same sequence:

retry requested: {"captionId":100,"source":"we are committed to the dual mandate."}
tryAssemble before retry result: null
tryAssemble after retry result:  "우리는 이중 책무에 전념합니다. 그리고 조정하겠습니다"

The span is recovered rather than dropped, and the line assembles complete.

The fix addresses both failure modes, which is more than I asked for. I reported the silent span loss; @dev identified that the wedge was a second, independent failure — isReady() waiting forever on a unit that could never arrive — so the drain-deadline wait "bought nothing and the data loss happened regardless". Both are handled:

  • streaming-assembly.ts:118-139noteUnitFailed now splits by timing: a pending unit still folds into the tail (original behaviour, unchanged); an awaiting unit returns a UnitRetry on first failure, and sets abandoned on a second rather than retrying forever.
  • streaming-assembly.ts:214-222isReady treats abandoned as settled (u.target !== null || u.abandoned), so an unrecoverable span yields a short line promptly instead of wedging until the drain deadline. The comment states the trade plainly: "a wedged line is worse than a short one."

The retry is actually wired — which is the check that matters here, since all three of the previous blockers were mechanisms written and tested but never called. src/host/session.ts:605-612 calls noteUnitFailed(id), and acts on the result: re-adds the id to unitIds and enqueues { id, text: retry.source } so the retry lands through the existing unit-result path under the same id, filling the slot in place. Guarded by this.runner && !this.stopping.

Seeded-violation proof, run myself rather than taken from the PR body. Reverting noteUnitFailed's awaiting branch to the pre-fix behaviour and dropping the || u.abandoned clause fails exactly two tests — asks for a retry when a unit fails AFTER its utterance finalized and gives up after one retry instead of wedging the line (2 failed | 203 passed). Reverted; tree clean. The new tests genuinely guard the finding rather than passing vacuously.

New coverage also includes a case I had not consideredtest/streaming-assembly.test.ts:124, "does not retry a unit belonging to an utterance that was cancelled", which is the interaction between the retry path and the #56/#62 bleed-drop path.

Carried forward from my 6172b4e review, all still verified at this SHA: default is Relaxed at the settings layer; live unreachable through every string→mode boundary, resting on SettingsState::replace() being the sole mutator and sanitising before both persist and the in-memory update; no_word_is_ever_released_twice asserting released.join(" ") == text (gaps and repeats, stronger than a dispatch count); and the picker copy's three condition-specific numbers matching the PR body's fixture tables.

Gates: app 205 passed (26 files) at 5d79490 in my worktree; all 5 CI greenapp-macos 2m11s, release-invariants 2m20s, packages-linux 40s, color-guard, no-stub-gate.

Note (non-blocking)

When the retry cannot be dispatched because the session is stopping (session.ts:609), the unit is left retried = true with no result and abandoned unset, so isReady stays false and the line waits for the drain deadline before forceAssemble emits it short. That is bounded and happens only during shutdown, so it is the designed path rather than a defect — but marking such a unit abandoned at that point would let the line assemble immediately instead of waiting out the deadline. Worth a line either way, so the next reader knows the stopping case is deliberate.

(The shared bot token cannot file a formal GitHub approval, so this comment plus my chat message is the RE2 verdict of record.)

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional delta blockers at 5d79490:

  1. Over-cap unit drops advance the Rust released_words watermark (crates/livecap-core/src/stable_prefix.rs:219-223) but HostSession.onTranslationUnit returns before recording the unit (src/host/session.ts:753-765). Finalized pretranslatedWords therefore skips dropped words, so the archive loses them. The cap must defer/coalesce without advancing coverage, or carry dropped source into finalize.

  2. Post-finalize retry is deduped: onFailed calls runner.enqueue while the failed id remains in inFlightIds (src/host/session.ts:600-612; src/host/translation-runner.ts:220-227), and enqueue rejects ids still in flight (:110-114). The promised same-id retry never dispatches; add a retry-after-finally path and regression.

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Epic Alignment: FAIL

The corrective delta fixes the original three blockers and adds the required Design Fidelity table, but introduces two new paths that can still silently lose source text.

Checked (evidence)

  • Delta reviewed: 6172b4e..5d79490; prior failed-unit/cancellation/cap wiring and UI fidelity additions inspected.
  • Design Fidelity table is present and its settings claims are documented with file:line citations.
  • Riskiest part: bounded streaming fallback and post-finalize retry; both currently have data-loss/dedup defects.
  • Kill-list: new ranges scanned — findings below.
  • CI: gh pr checks 212 → all five checks passing.

Findings

  • [blocker] Dropping an over-cap unit advances the released watermark and loses its source.

    • File: crates/livecap-core/src/stable_prefix.rs:219-223; src/host/session.ts:753-765
    • Why it fails: the tracker increments released_words before the host cap check drops the unit. Finalized pretranslatedWords still covers those words, so the finalize tail starts after the dropped span and the archive omits it.
    • Do instead: defer/coalesce before advancing coverage, or explicitly carry dropped unit source into the finalize tail; add an over-cap archive regression.
  • [blocker] Post-finalize unit retry is rejected by the runner's in-flight deduplication.

    • File: src/host/session.ts:600-612; src/host/translation-runner.ts:110-114,220-227
    • Why it fails: onFailed re-enqueues the same id synchronously while inFlightIds still contains it; enqueue returns early, and the retry never dispatches. The awaiting unit remains unresolved and forceAssemble drops it.
    • Do instead: add a retry-after-finally API/queue path (or clear/requeue atomically) and an integration regression proving a post-finalize failure actually retries and assembles.

Decision

Request changes. The prior blockers are addressed, but these new paths still violate the archive's no-silent-loss contract.

@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — WITHDRAWING my 5d79490 approval

@re1's two findings are correct. I verified both against 5d79490 myself, and one of them means my approval's central claim was false. Withdrawing comment 5187585656.

Finding 2 invalidates what I certified

I wrote: "The retry is actually wired — which is the check that matters here, since all three of the previous blockers were mechanisms written and tested but never called." I checked that the call exists. I did not check that it has effect. It does not:

  • translation-runner.ts:113enqueue returns immediately if this.inFlightIds.has(sentence.id).
  • translation-runner.ts:221onFailed(...) is invoked synchronously inside the catch.
  • translation-runner.ts:223-226finally clears inFlightIds after onFailed returns.

So the session's failure handler calls runner.enqueue({ id, text: retry.source }) (session.ts:611) while that id is still in inFlightIds, the dedup guard swallows it, and the retry never dispatches. The unit then sits retried=true, target=null, abandoned=false, isReady stays false, and forceAssemble drops the span — exactly the failure mode I reported at 6172b4e and then certified as fixed.

My probe was the reason I missed it: I called noteUnitResult(1, …) by hand to simulate the retry landing, which assumed the dispatch worked. The assembler half is genuinely fixed; the link I never exercised is the one that breaks. Verifying that a mechanism is called is one level short of verifying it works — a third variant of the same class this PR keeps producing, and this time I introduced it into the review rather than catching it.

Finding 1 — I accepted a comment as evidence

stable_prefix.rs:220 advances self.released_words = end; unconditionally on emit, with no knowledge of whether the host kept the unit. session.ts:761 drops the unit at the cap. So at finalize pretranslatedWords counts a span that was never dispatched, and the tail starts after it — the span is translated nowhere.

The comment at session.ts:756-760 asserts the opposite: "its text is not lost, because the finalize path translates everything past the released watermark." I read that comment, found it plausible, and did not trace the watermark. Taking prose as evidence is the exact failure I have been flagging in others all night, and I did it here.

What I'll do differently on the next SHA

Trace each claimed recovery path to its effect, not its call site: for the retry, an assertion that the span actually reaches the engine through the runner (@dev's planned end-to-end regression is the right level); for the cap, that a dropped unit's words are still covered by the finalize tail. Both are things a test can state and neither is something a comment can settle.

@dev's planned fixes look right to me — marking a capped unit failed so it folds into the tail through the existing mechanism rather than inventing a second concept, and staging the requeue for the finally rather than depending on microtask ordering. No objection to either shape.

#212 should not merge on my prior approval. I will re-review the corrective SHA when it is green.

(The shared bot token cannot file a formal GitHub review, so this comment plus my chat message is the RE2 verdict of record.)

Both confirmed against the code before changing anything; neither was a premise
error.

(1) Cap-drop lost its span. stable_prefix.rs:222 advances released_words when it
EMITS a unit, and the Rust tracker never learns the host declined one. So a unit
refused at the in-flight cap was still counted in pretranslatedWords at finalize,
the tail began after it, and those words were archived as source and never
translated. The comment I wrote at the drop site asserted the opposite - a wrong
comment defending a bug - and is gone.

The cap decision moves into StreamingAssembler.admitUnit, next to the count it
depends on and the failed-unit mechanism it now reuses: a refused span is
recorded and marked failed, so it re-translates in the finalize tail. A failed
unit is not counted in flight, so the cap still bites and cannot ratchet shut.
It also puts the decision somewhere testable - HostSession has no headless
harness, which is why the consequence went unproven the first time.

(2) The post-finalize retry never dispatched. onFailed runs in run()'s catch,
while the batch's ids leave inFlightIds only in the finally that follows it.
enqueue() dedups against exactly that set, so the retry was discarded silently
and the unit wedged to the drain deadline and force-assembled empty - the very
failure mode the retry was added to fix.

New TranslationRunner.requeueFailed stages the sentence and the finally flushes
it once the id is genuinely clear. Deliberately not solved by clearing
inFlightIds earlier (loosens #139 dedup for every other caller) or by a
microtask (correctness resting on task ordering no test would pin).

Regressions at the level that would have caught these: the runner tests drive a
real failing engine end-to-end, and one of them pins the defect itself - an
enqueue() from onFailed is swallowed.

Seeded-violation proof, both fixes reverted independently: 2 failed | 44 passed,
failing exactly on the reported symptoms - "eta theta" instead of "epsilon zeta.
eta theta" (span lost), and one engine call instead of two (retry never sent).

app 210 (+5). pnpm lint, typecheck (both configs), no-stub-gate, color-guard clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — REQUEST CHANGES (at 19b66f4)

Both defects are genuinely fixed — I verified the code, not the description. All 5 CI checks green, app 210 passing in my worktree. One finding, and it is about the regression coverage rather than the implementation: the seeded-violation proof does not cover the site where the defect actually lived.

Checked (evidence)

Both fixes are structurally correct.

  • Capstreaming-assembly.ts:104-112 admitUnit() records the unit then marks it failed at the cap, so its source folds into the finalize tail through the existing failure path; inFlightCount excludes failed units, so the cap cannot ratchet shut. Moving the decision beside the count it depends on is the right fix for why this shipped, not just for the symptom.
  • Retrytranslation-runner.ts:136-138 stages into retryAfterFailure; the finally clears inFlightIds first, then splices and re-enqueues. session.ts:607 now calls requeueFailed, not enqueue. Rejecting the two shortcuts (clearing inFlightIds before callbacks, or a microtask) is right — both would trade a real dedup contract for ordering nobody can pin.

The finding: the defect's own site is unguarded

I seeded each half independently, at the two places the defect could live:

seed result
B — runner never flushes staged retries (translation-runner.ts finally) 1 failedrequeueFailed() dispatches once the batch has cleared
Asession.ts:607 reverted to runner.enqueue(...), i.e. the exact defect @re1 reported 0 failed · 210 passed

So the new coverage proves the mechanism works and leaves the wiring unguarded — and the wiring is where the bug was. Anyone who later changes that one line back reintroduces the original data-loss defect with a fully green board. That is the same pattern this PR has now produced three times (noteUnitFailed with zero call sites, dropChannel with zero call sites, and this), and it recurs inside the fix for it.

I would not raise this as a general coverage complaint — I raise it because the PR body will carry a seeded-violation proof, and the seed that was run is not the seed that would have caught the reported bug.

In fairness to the constraint: session.ts:607 sits in HostSession, which has no headless harness — the documented Deviation. That is a real limit, and it is also precisely the reasoning that put a load-bearing decision there in the first place; the cap fix answers it by moving the decision out. So the options are symmetric with what has already been done here.

What I'd accept (any one):

  • extract the failure-handling branch the way admitUnit extracted the cap decision, so the choice of requeueFailed is assertable without spawning a session; or
  • a runner-level test that fails when a caller uses enqueue from inside onFailed — i.e. pin the contract rather than the call site; or
  • if neither is proportionate, say so explicitly in the PR body: that this line is unguarded, and why. An acknowledged gap is fine; an unnoticed one is what produced the last three rounds.

Carried forward, re-verified at this SHA

Relaxed default at the settings layer; live unreachable through every string→mode boundary; no_word_is_ever_released_twice asserting released.join(" ") == text; picker copy matching the fixture tables. All 5 CI green at 19b66f4app-macos 2m1s, release-invariants 1m58s, packages-linux 32s, color-guard, no-stub-gate.

(The shared bot token cannot file a formal GitHub change request, so this comment plus my chat message is the RE2 verdict of record.)

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Epic Alignment: FAIL

The new delta fixes the cap-watermark loss and retry dedup defects with runner-level regressions, but the cap still does not enforce the ticket's per-channel in-flight bound across utterance boundaries.

Checked (evidence)

  • Delta reviewed: 5d79490..19b66f4; cap admission and staged retry paths inspected.
  • Design Fidelity table remains present and previously verified against the unchanged UI.
  • Retry regression now exercises runner dedup and the cap regression preserves refused text for finalize.
  • Riskiest part: cap accounting across concurrent finalized/current utterances.
  • Kill-list: new ranges scanned — finding below.
  • CI: gh pr checks 212 → all five checks passing.

Findings

  • [blocker] In-flight cap ignores finalized utterances still awaiting unit results.
    • File: src/host/streaming-assembly.ts:70-88,180-205
    • Why it fails: inFlightCount counts only pending (the current utterance). onFinalized moves unresolved units into awaiting, where their target is still null, but they are omitted from the count. A new utterance can admit another two units while the previous utterance's units remain in flight, violating #195's max-in-flight-per-channel guard.
    • Do instead: count unresolved units in both pending and awaiting for the channel (or enforce an equivalent session-wide channel cap), with a finalize-then-new-utterance backlog regression.

Decision

Request changes. The cap is correct for one utterance but not for the required per-channel lifecycle, so sustained speech can still exceed the cost guard.

… document it

RE1: the cap escaped across utterance boundaries. inFlightCount counted only
`pending`, but finalize moves still-unresolved units into `awaiting`, so the next
utterance on that channel started from zero and could admit a full cap's worth
while the previous utterance's units were outstanding. The ticket's guard is per
channel, not per utterance. It now counts both, excluding abandoned units so a
dead unit cannot hold a slot shut for the rest of the session.

The test that asserted "finalize clears the count" asserted exactly the escape,
so it is rewritten to the new contract rather than deleted.

RE2: my seeded proof exercised the mechanism and left the wiring unguarded - and
the wiring is where all five defects on this PR have been. Two changes, because
documenting the gap was the weakest of the options offered.

First, requeueFailed is gone. enqueue now routes a retry of a just-failed id past
its own dedup, scoped to the ids being reported and only during the report, so
#139's dedup is untouched for every other caller. There is no longer a separate
retry entry point to forget - the wiring cannot be wrong because there is only
one call to make. That also fixes a second site I had missed: runOneToOne reports
per-sentence failures while the rest of the batch is in flight, so a fix applied
only to the whole-batch catch would have left those retries deduped. Both sites
now go through one reportFailed helper, and a test drives a real one-to-one
failure end to end.

Second, the failure routing itself is extracted as routeFailures, the same move
that admitUnit made for the cap: it decides unit-vs-caption and produces the
retries, so the decision is assertable instead of trusted.

Seeded-violation proofs, each fix reverted independently and run alone:
  cap counts only pending          3 failed | 25 passed
  enqueue ignores a failing id     2 failed | 22 passed
  unit failures routed as captions 2 failed | 26 passed
The third re-seeds RE1's original blocker 1, which nothing had covered.

app 216 (+6). pnpm lint, typecheck (both configs), no-stub-gate, color-guard clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — APPROVE (at 2b87978)

PR #212 (Fixes #195) @ 2b87978718ce0eed7b2679f8a862c46f9f1a81d0 — all 5 CI checks green. Supersedes my 19b66f4 REQUEST CHANGES and my withdrawn 5d79490 approval. Read with git show 2b87978:<path> and run at that SHA in my own worktree.

Checked (evidence)

Riskiest part: the retry path — closed structurally rather than by adding a test. My 19b66f4 finding was that the wiring (requeueFailed vs enqueue) was unguarded: reverting session.ts:607 reintroduced the data-loss defect with 210 tests still green. I offered three fixes, the cheapest being "state the gap in the PR body". @dev took none of them and removed the possibility instead:

  • requeueFailed no longer existsgrep -rn "requeueFailed" src/0 matches. There is no second entry point, so the wrong call cannot be made.
  • translation-runner.ts:114-125enqueue itself detects an id whose failure is being reported (failingIds) and stages the retry, scoped to the report window so [bug] Retranslate: failure erases archived translation + no dedup/priority (N6/N7) #139's dedup is untouched for every other caller.

My seed A is therefore not merely covered, it is inexpressible. That is the stronger outcome, and it is the right general move: eliminating a choice beats testing it.

Seeded-violation proofs, each reverted independently and run in my own worktree:

seed result
enqueue ignores a failing id 2 faileddispatches a retry enqueued from onFailed, past the in-flight dedup; dispatches a retry from a one-to-one turn's failure too
inFlightCount counts only pending (@re1's cap escape) 3 failed — incl. holds the cap across an utterance boundary

Clean run: 216 passed, 26 files. Both seeds reverted; tree clean.

The failure-report window is exception-safe and covers both sites. reportFailed (:294-301) adds ids to failingIds, calls onFailed inside a try, and clears them in finally — so an exception in a callback cannot leave ids permanently marked. Both sites route through it: the batch catch (:237) and runOneToOne (:281). The check I spent most time on: runOneToOne is awaited inside the batch try (:216), so retries it stages are flushed by the same finally that clears inFlightIds (:238-244). A second staging site without a flush would have been the identical defect in different clothing; it isn't there.

@re1's cap escape is fixed at the right level. inFlightCount now counts unresolved units in both pending and awaiting, excluding failed and abandoned so a dead unit cannot hold a slot shut for the session. The pre-existing test that asserted the escape ("finalize clears the count") was rewritten to the new contract rather than deleted — the right call, since deleting it would have removed the record of what changed.

And a failure site neither reviewer named. My finding was one wiring gap; @dev asked whether the critique generalised and found runOneToOne reporting per-sentence failures while the rest of the batch is in flight, which a per-site fix would have left deduped. Re-seeding @re1's round-1 blocker to demonstrate it had been uncovered until now is the correct response to a class-level critique.

Carried forward, re-verified at this SHA: Relaxed is the default at the settings layer; live is unreachable through every string→mode boundary, resting on SettingsState::replace() being the sole mutator and sanitising before both persist and the in-memory update; no_word_is_ever_released_twice asserts released.join(" ") == text, catching gaps and repeats; the picker copy's three condition-specific numbers match the PR body's fixture tables.

Gates: app 216 passed (26 files) at 2b87978 in my worktree; all 5 CI greenapp-macos 2m17s, release-invariants 2m15s, packages-linux 33s, color-guard, no-stub-gate.

Note (non-blocking, carried forward)

When a retry cannot be dispatched because the session is stopping, the unit stays retried with no result and abandoned unset, so the line waits out the drain deadline before assembling short. Bounded, shutdown-only, and the designed path — marking it abandoned at that point would let the line assemble immediately. Worth a line either way so the next reader knows the stopping case is deliberate.

(The shared bot token cannot file a formal GitHub approval, so this comment plus my chat message is the RE2 verdict of record.)

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

Epic Alignment: PASS

The final corrective delta closes the cap escape and failure-retry wiring defects while preserving the #195 archive and cost contracts.

Checked (evidence)

  • Delta reviewed: 19b66f4..2b87978; inFlightCount now counts unresolved units in both pending and awaiting (src/host/streaming-assembly.ts:76-112).
  • Failure routing: enqueue stages retries only for ids reported by reportFailed, then flushes after in-flight IDs clear (src/host/translation-runner.ts:114-133,235-247); both batch and one-to-one failures use that helper (:237,281).
  • Regression coverage: cap-across-boundary, abandoned-slot, unit/caption routing, whole-batch retry, and one-to-one retry tests (test/streaming-assembly.test.ts; test/translation-runner.test.ts).
  • Design Fidelity table remains present and previously spot-checked against the unchanged settings UI.
  • Riskiest part: concurrent finalized/current utterance cap and retry ordering; both now have effect-level tests rather than call-site-only checks.
  • Kill-list: delta scanned — clean.
  • CI: gh pr checks 212 → all five checks passing.

Findings

None.

Decision

The previously identified data-loss and cap-accounting paths are fixed and regression-tested at their wiring points. Approval granted for 2b87978.

@realproject7
realproject7 merged commit 993dbef into main Aug 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Simultaneous-style streaming translation — translation currently waits for utterance finalize (up to 30s during continuous speech)

2 participants