Skip to content

Send transcript usage after the gateway acks it; acked ids behind a held turn; 256 KB hold cap (0.24.0) - #41

Merged
davidcrowe merged 1 commit into
mainfrom
fix/transcript-usage-send-after-ack
Sep 21, 2026
Merged

davidcrowe merged 1 commit into
mainfrom
fix/transcript-usage-send-after-ack

Conversation

@davidcrowe

@davidcrowe davidcrowe commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Client half of davidcrowe/gatewaystack-connect#1279 (items 1 and 2). Gateway half: davidcrowe/gatewaystack-connect#1285.

What changed

Offset moves only after a 2xx. collectTranscriptUsage now returns { turns, ack, fail }; handlePostToolUse calls ack() after res.ok and fail() on non-2xx, abort or network error. A failed batch is re-read and re-sent by the next PostToolUse. After 3 attempts on the same batch the offset moves past it, one JSON line lands in ~/.acp/lapse.log (transcript-usage-dropped, first drop per transcript; later drops counted in the offsets file), and the ids are not marked acked. Previously: offset written before the request, process.exit(0) on !res.ok, silent catch — every failure was a permanent under-count.

Acked ids behind a held turn. transcript-offsets.json entries are now { off, acked, attempts, dropped } (bare number = pre-0.24.0 shape, still reads). acked holds the last 200 turn ids the gateway acknowledged for that transcript and is filtered out before a batch is built. The re-read behind a holdAt pin is now an empty send, not a re-send.

Also found while writing the test: holdAt was fixed at the first partial record the loop met, so a turn whose final record landed later in the same window still pinned the offset forever. The hold is now decided after the loop, on turns still incomplete at the end of the window; the existing "withheld, then billed" test asserts the release.

Held region capped at 256 KB. Past that the offset moves on with one transcript-hold-cap lapse line. If the abandoned turn's final record lands later it is a complete turn in a fresh window and bills at its real total (tested).

Version 0.23.0 → 0.24.0 in plugin.json, .claude-plugin/marketplace.json, PLUGIN_VERSION (the #40 convention; the repo has no CHANGELOG).

Adversarial pass

  • Can a huge transcript poison the acked set? No: only ids the gateway acked enter it, at most 50 per call, capped at 200 newest per transcript, 40 transcripts kept → offsets file ≤ ~250 KB worst case, parsed once per PostToolUse. A held region with >200 completed turns behind it (needs >200 assistant records inside 256 KB) would evict ids still in the window and re-send the oldest; the gateway's durable 500-id dedupe is the second line there.
  • Does the retry loop double-send when the gateway processed the batch but the response was lost? Yes, by design — that is now the only routine re-send, and the gateway's durable (tenant, session, id) dedupe (companion PR) is the answer; the per-process Set was not.
  • Cost of a held turn: ≤256 KB read + parse per call while held, no disk write in the steady state (the offsets file is written only when something changes).

Tests

node --test test/ → 254 pass (23 in transcript-model-usage.test.mjs, 8 new: 503 leaves offset + re-send; connection refused; three failures drop with one lapse line; legacy offsets file; zero already-acked ids on the second call after a held turn; hold releases on completion; 200-id cap; 300 KB held region advances once).

Release note

Hook hash changes. Registry needs the 0.24.0 hash after merge:
fddeccaa3b1db29fb48d1ebd95fc6a81333576105fc19df0ae1c11c6ea639ca5

Not merged, not published to npm or the marketplace.

…ehind a held turn; cap the held region (0.24.0)

gatewaystack-connect#1279, items 1 and 2, client half. Two accounting
defects in the hook's transcript collector, both introduced or exposed
by #40.

1. Silent under-count on any failure. collectTranscriptUsage wrote the new
offset BEFORE the request went out; handlePostToolUse then exited on !res.ok
and swallowed abort/network errors. There were no retries, and model_usage
is not in the offline ledger, so a 4xx/5xx/timeout on /govern/tool-output
lost that window's turns for good.

Now the collector returns { turns, ack, fail } and the offset moves only
in ack(), which runs after a 2xx. Any other outcome — non-2xx, abort,
connection refused — calls fail(): the offset stays, `attempts` is recorded
in transcript-offsets.json, and the next PostToolUse re-reads the same
window and sends it again. Bounded: after 3 attempts on the same batch the
offset moves past it, the loss is one JSON line in ~/.acp/lapse.log
(transcript-usage-dropped, logged on the first drop per transcript and
counted after), and the ids are NOT pretended acked.

2. Re-sends were the steady state. #40's holdAt pinned the offset at the
first incomplete turn so its remaining records get re-read — and every
completed turn behind it was re-SENT on every later tool call for the rest
of the session, relying on the gateway's per-process Set to absorb it.
Worse than the issue describes: holdAt was fixed at the first partial
record the loop met, so a turn whose final record landed later in the SAME
window still pinned the offset. The existing "withheld, then billed" test
now also asserts the offset releases.

The offsets file entry is now { off, acked, attempts, dropped } (a bare
number, the pre-0.24.0 shape, still reads). `acked` keeps the last 200 turn
ids the gateway acknowledged for that transcript; they are filtered out
before a batch is built, so the steady state behind a hold is a bounded
re-read and an EMPTY send. A re-send now happens in exactly one case: a
2xx whose response was lost in transit. The gateway's durable (session, id)
dedupe (gatewaystack-connect PR, same issue) is what absorbs that.

3. The held region is capped at 256 KB. Past that the offset moves on, one
lapse line says so (transcript-hold-cap), and the incomplete turn is
abandoned — unless its final record does land later, in which case it is a
complete turn in a fresh window and bills at its real total. Previously
only TRANSCRIPT_READ_CAP (2 MB) bounded the stall, which a normal session
never reaches, so every tool call paid the re-read.

Tests (test/transcript-model-usage.test.mjs, 23 cases): a 503 leaves the
offset and the next call re-sends the identical window; connection refused
is a failed send too; three failures drop the batch with one lapse line and
the gateway coming back does not re-send it; the legacy bare-number offsets
file still reads; the second PostToolUse after a held turn sends zero
already-acked ids and the hold releases when the turn completes; the acked
set is capped at 200 newest; a 300 KB held region is advanced over once.

Note for release: the hook's content hash changes with this, so the
registry needs the 0.24.0 hash AFTER merge, never before:
  fddeccaa3b1db29fb48d1ebd95fc6a81333576105fc19df0ae1c11c6ea639ca5

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidcrowe
davidcrowe merged commit 09850fe into main Sep 21, 2026
2 checks passed
@davidcrowe
davidcrowe deleted the fix/transcript-usage-send-after-ack branch September 21, 2026 21:02
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.

1 participant