Skip to content

[Bug]: Terminal transcript reconciliation discards observed usage #1040

Description

@dennisonbertram

Work type

Bug / regression

Observed behavior

On PR #1033, GitHub Actions macOS live-harnessd run 30551192571 rendered the real prompt/reply and completed the run, but RunSessionLiveTests.submitProducesTranscript observed session.transcript.usage.totalTokens == 0. A focused fresh-harness rerun held the combined completed/nonzero-usage condition for 20 seconds and still timed out, proving this is not a short assertion race.

RunSession.streamConversation fetches durable messages at every terminal event and calls Transcript.reconcile. That method calls load, which constructs a fresh Transcript and therefore clears usage already applied from usage.delta. The duplicate per-run event is then rejected by seenEventIDs, so usage never returns.

Expected behavior

Durable message reconciliation may replace persisted transcript rows, but it must preserve already-observed authoritative run metadata that messages do not contain, including cumulative usage. Completed callback/cron/user runs must retain nonzero accounting after terminal reconciliation and replay.

Reproduction

Preconditions: macOS, key-free harness from macapp/scripts/live-harnessd.sh.

Command: start the live harness, then run HARNESS_TEST_BASE_URL=http://127.0.0.1:8899 swift test --package-path macapp --filter RunSessionLiveTests.

CI evidence: run 30551192571, job 90899990017.

Focused local red: run completed, the combined completion/usage wait timed out after 20 seconds, and the final assertion observed zero tokens.

Reproduction rate: deterministic with the current terminal reconciliation path.

User and operational impact

Severity P1: native users lose the token/cost status after every terminal message reconciliation, and the red live gate blocks the cron/callback GUI merge chain. Transcript content survives; no persistent-data or security impact.

Suspected seam and search evidence

Owning seam: Transcript.reconcile(messages:) in macapp/Sources/HarnessKit/Transcript.swift, called by RunSession.reconcilePersistedMessages after conversation-wide terminal events.

Searched both RunSession SSE tasks, event-ID dedupe, Transcript.applyUsage, harness usage.delta, live fake turns, durable message payloads, and all reconcile callers/tests. Every fake turn has nonzero usage. Durable messages do not carry usage. load resets the whole value, and the subsequent duplicate usage event is intentionally deduped.

Blast-radius impact map

  • Callers/data flow: harness usage.delta -> Transcript usage -> terminal conversation event -> durable messages -> reconcile/load.
  • Config/env/defaults: None.
  • API/CLI/wire formats/tools: no change.
  • Persistence/schema/cache: durable messages unchanged; they remain row source only.
  • Concurrency/lifecycle: preserve metadata across terminal reconciliation from two SSE streams.
  • Security/auth/privacy: None.
  • TUI/web: None.
  • macOS: usage/cost label and environment inspector retain authoritative values.
  • Provider/model/catalog: no routing change; all provider accounting benefits.
  • Deployment/observability: GitHub macOS live gate.
  • Compatibility: additive state preservation, no wire/schema change.
  • Existing tests/fixtures: Transcript reconcile unit tests, conversation-stream integration, RunSession live suite.
  • Documentation: engineering log, plan, impact map, indexes.

Regression test first

Test file/name: RunSessionConversationStreamTests.completedReplayPreservesObservedUsage (or the smallest equivalent Transcript reconcile unit).

Red fixture: deliver usage.delta with nonzero cumulative usage, then run.completed; stub durable messages and let terminal reconciliation run.

Expected failure: totalTokens resets from the scripted value to zero.

Why it proves the bug: it drives the exact conversation-wide terminal -> messages -> reconcile path used by GUI callback/cron/user runs.

False-positive controls: assert durable rows still deduplicate, completion remains terminal, usage value is exact, and historical load for a newly opened conversation still starts from zero.

Fix boundaries

In scope: snapshot usage before Transcript.load inside reconciliation and restore it afterward; add exact conversation-stream regression and retain the live combined postcondition.

Out of scope: persisting usage in message rows, serializing SSE streams, changing event IDs/dedupe, changing server accounting, sleeps, or provider scripts.

Existing abstraction: repair Transcript.reconcile; no parallel state owner.

Diagnostic and observability evidence

Preserve the exact CI/local red logs and exact expected usage totals in the regression. The live test remains bounded and fails if accounting is still absent.

Verification plan

  • Deterministic conversation-stream red/green test.
  • Transcript reconciliation and dual-stream focused suites.
  • Focused live suite against a fresh harnessd, repeated.
  • Complete Swift suite and strict format.
  • Full ./scripts/test-regression.sh normal/race/coverage.
  • Re-run macOS CI live job.

Rollout and rollback

Native state-only repair. Land before rebasing #1033. Roll back if reconciliation leaks usage across conversation changes; load/reset controls must remain zero. No data repair.

Documentation and handoff

Update docs/logs/engineering-log.md, docs/logs/long-term-thinking-log.md, docs/plans/INDEX.md, plan, and impact map. Related epic: #1000.

Definition of done

  • Exact live failure reproduced
  • Deterministic conversation-stream regression failed before fix
  • Reconcile preserves exact observed usage without cross-conversation leakage
  • Durable transcript dedupe and terminal state remain correct
  • Focused live, full Swift, and full regression green
  • GitHub macOS live gate green
  • Engineering log, plan, impact map, and indexes current

Process acknowledgement

I will preserve the failing regression test, update the issue if the root cause changes, and record any newly discovered bug as its own issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions