Deferred #843 findings: listener counters, the refusal log, and the usage a failed batch consumed - #851
Conversation
… the usage a failed batch consumed Six of the ten findings deferred from PR #818, the ones that are a bug with a reproducing test rather than a design call. - A batch whose message write fails no longer loses the usage it claimed. The usage index outlives one POST because an `api_request` and the `assistant_response` that names its `request_id` routinely arrive in different flushes; projection claimed the entry, the write then failed, and the exporter's retry re-projected against a drained index and wrote the same rows with no `attributes.usage` and no `claude.cost_usd`. The catch puts back only what the batch consumed. - `client_attach_stale` no longer fires at an `otel`-attached client. The marker records the gateway port that mode never uses, so a gateway rebind printed a re-attach warning at a client the rebind did not touch; `client_telemetry_stale` already watches the port that decides whether anything is captured. - A refused `body_ref` is logged as a 12-hex digest, not as the raw wire-supplied path (LLP 0257 S23). - `deleteSpooledBodies` counts what it removed. `fs.rm(..., { force: true })` succeeds on a missing path, so `bodies_deleted` and `bodies_dropped` over-reported every already-evicted ref. - A policy drop brings `spool_bytes` down with the files it deleted, instead of leaving the gauge high until the next sweep restates it. - `last_event_at` is maxed by instant, not by string. Mixed `Z` / `.000Z` / offset shapes let the capture-gap baseline run backwards. The other six findings stay open on the issue: the picker's proxy-mode contradiction between two Accepted docs, the `dedupeStoredPartIds` scan cost, `hyp session status` reading only the gateway, the `lastEventAt` stamping order, the telemetry-endpoint prevention half, and the two shapes in finding 10 - each needs a decision this PR is not the place to make. Refs #843 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#853) `test/core/repo-scratch-hygiene.test.js` has been failing on `master` since it merged: `x/npm-test.log` and `x/typecheck.log` are tracked, and the test's first half asserts no `.log` is. Both came in on `adb448ab` (#785) via the `git add -A` sweep that #786 wrote this test to catch; the files predate the test, so it was red on arrival. Every branch cut since inherits it, currently blocking #833, #849, #850 and #851 for a reason none of them caused. The transcripts are deleted rather than the test relaxed, which is what its message asks for. `.gitignore` needs nothing: `*.log` is already committed and the rule test already passes, since an ignore rule cannot reach a path that is already tracked. That asymmetry is the whole reason the file carries two tests. Scope is exactly the two `.log` paths. The other eight files under `x/` are untouched: the hygiene test forbids tracked transcripts and nothing else, and sweeping up scratch it does not name would be a judgement this fix has no authority to make. Fixes #852 Co-authored-by: test <test@test.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
x/npm-test.log and x/typecheck.log are tracked leftovers from the git add -A sweep in #785 (pre-dating the repo-scratch-hygiene test added by #786). master already carries this fix via #853; this branch forked before that landed, so it inherited the red test separately. Same fix, scoped to the same two paths.
…ytes counts what left the disk Two follow-ups from the review of this branch, both in the code it added. `newerEventTimestamp` fell back to the string compare whenever EITHER side failed to parse. `event.timestamp` is read off the wire unvalidated (`telemetry/events.js` takes whatever string the attribute carried), so a producer that stamps a non-date wins that compare outright - nothing an ISO stamp can begin with sorts above `u` in `unknown` - and because the fallback is symmetric it then beats every genuinely newer event that follows. The baseline is pinned for the life of the daemon, `hyp status` parses it to undefined, falls back to `listener_started_at`, and raises `capture_gap` against a listener that is capturing fine. A value that names an instant now beats one that names none, whichever side it arrives on; the string compare survives only for the pair where neither parses. The projected-body arm still subtracted `spooled.consumedBytes`, the bytes READ, while the PR added `bytesRemoved` precisely so the gauge tracks what left the disk. A body whose unlink fails (EPERM, a read-only spool) is still occupying the cap, and deducting it under-reported `spool_bytes` until the next sweep restated it: the drop arm's bug in the other direction. The new listener case fails on 40e40f9 and passes here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of
|
…and a ref stops citing S18 for something S18 does not say The catch-block restore reinstated every key missing from the pre-projection snapshot, which includes the ones USAGE_INDEX_LIMIT evicted during the batch, not only the ones projection claimed. During a sustained dataset outage nothing is ever claimed and nothing ever shrinks the index, so each failed batch added its `api_request` entries on top of a map that could no longer trim itself and the 512 cap stopped bounding anything. The restore is now `restoreUnclaimedUsage`, which puts the snapshot back and re-applies the cap, with a unit test that drives 40 failed batches through the real projector and asserts the index settles at the cap instead of growing past it (it reaches 576 by round 8 without the trim). The `@ref LLP 0257#failure-modes` glosses on the source and on the accounting test cited "S18 - a retried batch is re-projected from the same inputs, so its inputs have to survive". S18 says delivery is best effort and that a down daemon's behavioral-event loss is accepted; it says nothing about retry inputs surviving. 0257 is Accepted, so the mis-citation is removed rather than the spec edited, and the prose above each already carries the reasoning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neutral review round: PR #851 @
|
|
Neutral triage at head |
…eted (#904) Deferred finding 3 from PR #851's triage. `loadSpooledBodies` deletes a body file that does not parse (an undeleted body is a raw prompt sitting on disk) but reported only a count, so the listener's call site had no size to subtract from `state.spoolBytes`. The gauge stayed at its pre-batch value until the next sweep restated it, up to the 60s sweep interval, and `hyp status` published bytes for a file already off disk. `loadSpooledBodies` now returns `unparseableBytes`, sized only once the unlink actually succeeded (a file still on disk is still occupying the cap), and the receive handler subtracts it beside the existing `bodiesUnparseable` counter. Findings 1, 2 and 4 are not reachable from master: they describe code PR #851 introduces and that PR is still open. Finding 5 is a spec question for a maintainer. All four remain deferred on #904. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eted (#904) (#912) * An unparseable body leaves spool_bytes reporting bytes it already deleted (#904) Deferred finding 3 from PR #851's triage. `loadSpooledBodies` deletes a body file that does not parse (an undeleted body is a raw prompt sitting on disk) but reported only a count, so the listener's call site had no size to subtract from `state.spoolBytes`. The gauge stayed at its pre-batch value until the next sweep restated it, up to the 60s sweep interval, and `hyp status` published bytes for a file already off disk. `loadSpooledBodies` now returns `unparseableBytes`, sized only once the unlink actually succeeded (a file still on disk is still occupying the cap), and the receive handler subtracts it beside the existing `bodiesUnparseable` counter. Findings 1, 2 and 4 are not reachable from master: they describe code PR #851 introduces and that PR is still open. Finding 5 is a spec question for a maintainer. All four remain deferred on #904. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Review fixes: only the call that removed the body reports its bytes, and the refs name the spec that owns the gauge Three review findings on the unparseable-body byte accounting. `fs.rm(file, { force: true })` RESOLVES for a path that is already gone, so "sized only once the unlink succeeded" did not hold: two reads of the same `body_ref` in flight at once (the receive handler is not serialized, so an exporter retry overlaps the original it retries) both read the file, both call it unparseable, and both added `raw.length` - `spool_bytes` came down by twice one deletion. `fs.unlink` makes the guard the comment describes real: the second call rejects with ENOENT and reports nothing. New regression case proves it, 30 bytes before and 15 after for one 15-byte file. The comment claimed the sizing matched the projected arm. It does not on this head: the projected arm subtracts `spooled.consumedBytes` unconditionally at the call site whether or not `deleteSpooledBodies` removed anything. It also said an undeleted file is "occupying the cap", but `state.spoolBytes` is a published gauge only - the cap is enforced by `enforceClaudeBodySpoolCap` re-scanning the directory. Both claims replaced with what the code does. Both `@ref`s cited LLP 0253#byte-cap with a gloss reading "whichever arm removed the file". The published gauge is LLP 0257 S16's duty (#status-and-health), not the cap's, and the "whichever arm" claim is not true: the policy-drop arm still deletes bodies without subtracting, because the delete-unread path reports no bytes. Anchors moved to 0257 and the overclaim replaced with a note of the arm that still lacks it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: test <test@test.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six of the ten review findings deferred from PR #818 onto issue #843: the ones that are a bug with a reproducing test, rather than a decision. Each fix has a regression test that fails on
masterand passes here. The remaining four items are listed at the bottom and stay open on the issue, so this PR saysRefs #843, notFixes.Fixed here
Finding 5 - a retried batch lost its usage and cost. The usage index outlives one POST on purpose: the exporter flushes on a timer, so a turn's
api_request(tokens,cost_usd) and theassistant_responsethat names itsrequest_idroutinely arrive in different flushes. Projection claims the entry; before this, arecordProjectedExchangethat then threw left it claimed, and the exporter's retry re-projected against a drained index and wrote the same assistant rows with noattributes.usageand noclaude.cost_usd- permanently, in exactly the batch that had already failed once. The catch now puts back only what this batch consumed, so anapi_requestwhose response has not arrived yet is untouched. (telemetry/source.js)Finding 7 -
client_attach_stalefired at anotel-attached client. Anotelmarker records the gateway port like every other marker does, but nothing that client sends goes there. A routine gateway rebind printed "attached at port X but the gateway is now bound to Y - re-attach" at a client whose capture the rebind did not touch. The comparison is now gated on the attach mode;client_telemetry_stalestill watches the port this mode actually depends on. (src/core/daemon/status.js)Finding 8 -
body_reflogged verbatim. A refused ref is out-of-spool by definition and arrived over the wire, so the warn line carried an unvalidated absolute path into a signal an operator's own sink may ship off the machine. It is now a 12-hexbody_ref_sha256, which still correlates repeats of one ref across lines, per LLP 0257 S23. (telemetry/bodies.js,telemetry/source.js)Finding 9a -
deleteSpooledBodiescounted ENOENT as deleted.fs.rm(..., { force: true })succeeds on a path that is not there, sobodies_deletedandbodies_droppedover-reported every already-evicted ref. It now stats before the unlink and counts only what it removed. The pinned assertion intest/plugins/claude-telemetry-bodies.test.jsis updated to the honest contract.Finding 9b -
spool_bytesdrifted high across a drop.suppressSessiondeletes body files unread, and nothing subtracted them, so the gauge only came back down at the next sweep (up to a minute later) whilehyp statusreported bytes for content already removed on the user's say-so.deleteSpooledBodiesnow returnsbytesRemovedand the drop arm subtracts it.Finding 9c -
lastEventAtwas maxed by string comparison.event.timestamparrives in mixed shapes (...:24Z,...:24.500Z, and a legal OTLP numeric offset), and by text...:24Zsorts after...:24.500Z. The publishedlast_event_atis the baselinehyp statusmeasures a capture gap from, so a max that runs backwards invents a gap. Now compared as instants, with the old string compare kept as the fallback for an unparseable value.Tests
test/plugins/claude-telemetry-listener-accounting.test.js(new) - drives a real listener on an ephemeral port with a fake gateway and storage behind it and POSTs OTLP/JSON at it: findings 5, 8, 9a, 9b, 9c. All five fail onmaster.test/core/status-otel-attach-stale.test.js(new) - finding 7, plus a companion case proving abase_urlattach in the same shape still gets the warning.test/plugins/claude-telemetry-bodies.test.js- the twodeleteSpooledBodiesassertions updated for the counting fix.npm testandnpm run typecheckare green locally, with one pre-existing exception:no tool transcript is tracked in the repofails onmastertoo, becausex/npm-test.logandx/typecheck.logwere committed in #785 (adb448ab). It is untouched by this PR and left for whoever owns that tree.Still open on #843
No code here, and no
Fixes, because each of these needs a call this PR cannot make:dedupeStoredPartIdsscans the whole dataset per live write - bounding it (partition pruning, or a part_id index) is a dedupe-contract design call.hyp session statusasks only the gateway - the fix changesSessionStatusReport's shape, and hyp status names the CA's permitted hosts, uninstall clears every duplicate root (#793) #800 is still open on the same neighbourhood.lastEventAtis stamped before both policy gates - the ordering itself is the question the issue flags (moving the stamp false-alarms on deliberately-hypignored machines); the honest fix it names is a new diagnostic overevents_undetermined. Note 9c above changes only how two timestamps are compared, not when the stamp happens.hyp purge's consent sentence - both are shapes the issue itself marks as needing a decision.Refs #843