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
Sync can double-count copilot usage at the receiver — spans are sent once, but reconciliation output changes #988
codeburn sync push (opt-in) flattens every served call into one OTLP span (collectUnsentCalls → buildOtlpPayload), records each deduplicationKey in the sync ledger, and never sends that key again. Span identity is also derived from the same key (deriveSpanId). The whole pipeline therefore assumes a served call is immutable: same key, same value, forever.
Copilot's serve-time reconciliation (#946) is the first producer of served calls whose values and roles change between passes while their keys stay stable. Sent once and never corrected, they double-count at any receiver that sums tokens/cost or counts spans as requests. Local reports are unaffected — every local surface re-reconciles per pass; this is exclusively about what leaves the machine.
Two failure modes
1. Residuals shrink after they're sent. A residual serves the part of a rollup leg its store rows haven't covered yet, under a stable key (copilot:<sid>:shutdown-residual:<model>:<leg>). Concretely: a leg records 10,000 input tokens; at push time 4,000 are covered by rows, so the receiver gets row spans (4,000) plus a residual span (6,000) — correct at that moment. The remaining rows then land: locally the residual retires to 0, but the receiver keeps the 6,000-token residual span forever (its key is ledgered) and receives the new row spans (6,000). Remote total: 16,000 for a 10,000-token leg.
2. Pairing state flips after a row is sent. A store row synced while unpaired is a behavioral call — one real request. If its journal-side per-turn call appears later (a recovered or resumed session), pairing marks the row supplementary locally, but the already-ledgered row span is never revisited, and the journal call syncs as well. The receiver counts two requests for one.
What's new vs pre-existing
Pre-existing: spans have never equaled requests — shutdown rollup calls have always synced as one span per (session, model) leg — and the CLI reports raw span counts as calls ("Synced N calls", src/sync/cli.ts). Mislabeled, but stable: pre-feat(copilot): read per-request input/cache from session-store.db #946 every synced value was immutable, so the ledger model was sound.
Mark supplementary spans (e.g. ai.supplementary_accounting) so receivers can assign them zero request weight. Marking alone is insufficient — under the append-once ledger the marker's value is race-dependent.
Give mutable reconciliation output an identity compatible with send-once semantics: value-versioned keys (a superseding residual re-sends and the receiver keeps the latest), or exclude the mutable classes (residuals, and possibly rollups) from sync and let receivers derive them — filtering supplementary rows outright is not an option, since it would erase recovered usage from the receiver's ledger.
Relabel CLI counts as records/spans, or report behavioral calls separately.
Landing the smallest correct half of this alongside #946 rather than shipping receiver totals that can go permanently wrong.
Value-versioned keys (option 2) need a receiver-side upsert that usage spans don't have — docs/sync/README.md documents upsert only for attribution spans ((git.repo, git.sha), ai.session_id, latest wins); usage spans are summed, so a superseding span adds rather than replaces. Giving them one is a protocol change and belongs here, not in #946.
Holdback: a Copilot session is held until it has been quiet for 24 h, then sent once, final. Every input to the reconciliation (rows per request, rollup at shutdown) is written during the session, so a quiet session cannot reconcile further. The whole session is held, not just the residual (holding only the residual still ships a row whose pairing can flip; holding only rows still ships a rollup that's about to be dropped). Undatable sessions are sent rather than held forever; stamps > 1 h in the future are ignored for settle.
Shape freeze (upgrade transition + age-out): a session's input/cache leaves the machine in one of two shapes — rollup (copilot:<sid>:shutdown:…, what every pre-store version sent) or reconciled (copilot-store: rows + shutdown-residual). Whichever shape a session was first synced in, it stays in; the other is frozen for that session permanently, in both directions. A receiver that already holds 0.9.20 rollups never also gets rows for those sessions; rows-first sessions never later get a rollup when the 90-day age-out un-drops it. --dry-run reports held and frozen; codeburn sync reset --confirm is pointed at with the caveat that the receiver copy must be cleared too.
Residual keys are now derived from the leg's timestamp (not positional), so a later-discovered earlier leg can't rename an already-sent residual.
Left for this issue: ai.supplementary_accounting marking (now unblocked — under the holdback its value is no longer race-dependent), relabeling "Synced N calls" as records/spans, an upsert contract for usage spans if we ever want sub-day Copilot latency, and the 24 h constant (conservative, unmeasured — someone with real Copilot data should check how long after session.shutdown the last store row lands). Also new: the receiver operator has no visibility into which sessions are frozen beyond --dry-run.
Closed by #946 (merged 5042139): the sync push path now holds calls inside the RECONCILE_SETTLE_MS window and freezes each session's shape (rollup XOR reconciled) before sending, so reconciliation output can no longer change after spans were sent — the receiver-side double-count this issue described is structurally prevented. Validated across three machines during #946's six-round creator testing.
Activity
fix(sync): hold a copilot session until its reconciliation can no lon…
iamtoruk commentedon Aug 19, 2026
Landing the smallest correct half of this alongside #946 rather than shipping receiver totals that can go permanently wrong.
Value-versioned keys (option 2) need a receiver-side upsert that usage spans don't have —
docs/sync/README.mddocuments upsert only for attribution spans ((git.repo, git.sha),ai.session_id, latest wins); usage spans are summed, so a superseding span adds rather than replaces. Giving them one is a protocol change and belongs here, not in #946.What #946 now does in
collectUnsentCalls:copilot:<sid>:shutdown:…, what every pre-store version sent) or reconciled (copilot-store:rows +shutdown-residual). Whichever shape a session was first synced in, it stays in; the other is frozen for that session permanently, in both directions. A receiver that already holds 0.9.20 rollups never also gets rows for those sessions; rows-first sessions never later get a rollup when the 90-day age-out un-drops it.--dry-runreportsheldandfrozen;codeburn sync reset --confirmis pointed at with the caveat that the receiver copy must be cleared too.Left for this issue:
ai.supplementary_accountingmarking (now unblocked — under the holdback its value is no longer race-dependent), relabeling "Synced N calls" as records/spans, an upsert contract for usage spans if we ever want sub-day Copilot latency, and the 24 h constant (conservative, unmeasured — someone with real Copilot data should check how long aftersession.shutdownthe last store row lands). Also new: the receiver operator has no visibility into which sessions are frozen beyond--dry-run.iamtoruk commentedon Aug 22, 2026
Closed by #946 (merged 5042139): the sync push path now holds calls inside the RECONCILE_SETTLE_MS window and freezes each session's shape (rollup XOR reconciled) before sending, so reconciliation output can no longer change after spans were sent — the receiver-side double-count this issue described is structurally prevented. Validated across three machines during #946's six-round creator testing.