Send transcript usage after the gateway acks it; acked ids behind a held turn; 256 KB hold cap (0.24.0) - #41
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
collectTranscriptUsagenow returns{ turns, ack, fail };handlePostToolUsecallsack()afterres.okandfail()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.jsonentries are now{ off, acked, attempts, dropped }(bare number = pre-0.24.0 shape, still reads).ackedholds the last 200 turn ids the gateway acknowledged for that transcript and is filtered out before a batch is built. The re-read behind aholdAtpin is now an empty send, not a re-send.Also found while writing the test:
holdAtwas 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-caplapse 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
(tenant, session, id)dedupe (companion PR) is the answer; the per-process Set was not.Tests
node --test test/→ 254 pass (23 intranscript-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:
fddeccaa3b1db29fb48d1ebd95fc6a81333576105fc19df0ae1c11c6ea639ca5Not merged, not published to npm or the marketplace.