offload-scan: per-invocation cost may include session-wide activity, not just the skill's own turns #12
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Split out of kotkan/claude-plugin-inference-arbitrage#11, whose primary defect (evidence-strength instability across windows) is fixed by the FR-4.5 gate in commit
dcec9ca. This is the secondary finding from that issue, unaddressed.bin/offload-scanreportedanxious:agent-wipat roughly 989k weighted tokens per invocation. That skill's actual job is on the order of ten MCP calls. A figure that large suggests invocation reconstruction or attribution is folding whole-session activity into a per-skill cost, rather than isolating the turns the skill was responsible for.Why it matters:
offload_valueandshare_of_audited_spendare the numbers the filing threshold (spec §5.2.7) is applied to, and the numbers an issue body presents to a human as the cost of a candidate. If they are inflated by unrelated session work, the plugin over-reports value and files candidates that are not worth what it claims.Acceptance:
offload-scan's invocation reconstruction actually includes for an agent-driven skill — where the invocation boundary is drawn, and whether sidechain turns belonging to other work land inside it.anxious:agent-wipinvocation's turn set against the transcript structure (shapes and counts only, FR-3.5).Investigation: root cause identified, and it's not what the acceptance criteria expected
Summary: The title's framing ("session-wide activity... not just the skill's own turns") is partially accurate but for a different mechanism than the acceptance criteria's bullet 1 anticipated. Two things are going on, and they cut in different directions:
1. Turn attribution itself is correct — not a bug
Checked
bin/offload-scanlines 401-421: theminecheck requiresattr_plugin == pluginorattr_agent/attr_skillto start with"<plugin>:". Unattributed turns are dropped immediately (last_error = False; pending_reads = []; continue) and never enter theturnslist. I hand-verified this against a real transcript:That session (
cd01f9c2-...) has 1385 total assistant turns spanning 2026-07-13T03:22 → 2026-07-16T17:06 (a multi-day, multi-purpose session touchinganxious:delivery-flow,anxious:agent-wip,memory:save). Of those, exactly 31 distinct turns carryattributionSkill == "anxious:agent-wip"(deduped by(message.id, requestId), matching offload-scan's own dedup key). All 31 haveisSidechain: False— i.e. inline main-thread turns, not a leaked subagent sidechain. So acceptance-criteria bullet 1's specific worry (sidechain turns from other work landing inside the group) is checked and ruled out: nothing session-wide is being wrongly counted as "mine."2. The real mechanism: cache-read/write cost per turn is by design, and it's documented
design/spec.md§1.1 states this explicitly as the plugin's founding premise:and §5.2.1:
mechanical_tokens = Σ over mechanical turns of (cache_read + cache_write + input + output)— "Becausecache_readdominates, it is very nearlymechanical_turns × mean_context_size."Real numbers from the same 31 turns confirm this is exactly what's happening:
cache_read_input_tokensclimbs from 129,437 to 138,866+ across the run (it's the whole accumulated session context, re-read on every turn, per Anthropic's cache-read semantics) — andcc-tokens'Rec.weighted(bin/cc-tokensline ~113) includescr * CACHE_READ_MULT(0.1×) andcw1 * CACHE_WRITE_1H_MULT(2×) unconditionally, with no per-skill isolation. This is intentional and causally correct foroffload_value: replacing agent-wip's LLM turns with a deterministic script genuinely eliminates those turns and their context-read cost, so counting the full per-turn context cost against the skill is not an over-attribution bug — it's the tool's entire reason to exist. No code change indicated here.3. The actual unaddressed defect:
reconstruct_invocationscan merge distinct real invocationsbin/offload-scanlines 523-545 (unchanged since Phase 3, commitbdb62da— confirmed viagit log --oneline -- bin/offload-scan, no later commit touches this function, including today's FR-4.5/audit fixes indcec9ca/1777ee1/f52bb79/e3b67e9/6b634fd):splits an "invocation" only on a group change or a wall-clock idle gap (
idle_gap_minutes: 10, perreferences/signals-catalog.md) between consecutive already-attributed turns. Because unattributed turns never enterturnsat all, this heuristic is blind to how much unrelated session activity happened in a gap — it only sees the clock. Concretely, in the same 31-turn sample:All 31 turns collapse into one reported "invocation." But the issue body itself says agent-wip's job is "on the order of ten MCP calls" — 31 turns is ~3× that, consistent with 3 separate real calls (e.g. claim #A, release #A, claim #B...) landing back-to-back in the same session and getting merged into a single reported invocation. This inflates the per-invocation headline figure (
weighted_tokens / invocations, e.g. "989k weighted tokens per invocation" or the #15-body's "3,900,668 weighted tokens over 12 invocations") without being wrong about the total — the total groupweighted_tokenssum is unaffected by invocation splitting. It also meansmine_ngrams'smin_invocations: 3gate (repetition_factor) can undercount recurring patterns that span merged invocations (they show up as 1 invocation instead of 3), which would understateoffload_value, not inflate it — so the merging defect and the "over-reports value" framing in the issue don't straightforwardly line up; the merging defect specifically corrupts the per-invocation number, notoffload_value/share_of_audited_spend(which are computed on the whole group, ungrouped by invocation).Verdict
reconstruct_invocations's idle-gap-only split can merge multiple genuine invocations of a skill into one when they occur close together in wall-clock time, which inflates the per-invocation figure quoted to humans in filed issue bodies (notoffload_value/share_of_audited_spend, which remain correct).design/spec.md§1.1/§5.2.1, and correct for the tool's stated purpose.Recommended fix (not applied in this pass — diagnosis only)
In
bin/offload-scan,reconstruct_invocations(): either (a) tighten the split heuristic so a fresh run of the same skill/agent that starts a recognizable "entry" tool-signature after a shorter gap still splits (heuristic, imperfect, since transcripts carry no explicit invocation-start marker), or (b) at minimum, add a caveat to the emitted JSON (e.g. anotefield) and tobin/audit-snapshot's issue-body template stating "reconstructed invocation count is a lower bound; back-to-back same-skill calls withinidle_gap_minutesare merged." Given there is no ground-truth invocation boundary in the transcript data, (b) is the safer, always-correct fix; (a) is a heuristic improvement with false-negative/false-positive tradeoffs of its own.Files:
bin/offload-scan(reconstruct_invocations, ~L523-545),bin/audit-snapshot(issue-body template, ~L465).— investigated per kotkan/claude-plugin-inference-arbitrage#12, repo confirmed clean/main throughout, no local Nix/build/deploy steps involved (n/a for this repo).
Root cause
The issue title/hypothesis was "sidechain leakage" — investigated and ruled out. Turn attribution in
bin/offload-scan'sminecheck (L401-421) is correct: only turns whoseattributionPlugin/Agent/Skillmatch the target plugin enterturns; nothing session-wide leaks in as "mine". Hand-verified against a real 1385-turn transcript (~/.claude/projects/-home-oleks-projects/cd01f9c2-0c38-4bd1-99ef-6145f8988dce.jsonl): all 31 attributedanxious:agent-wipturns areisSidechain:Falseand correctly tagged.The real, narrower defect is in
reconstruct_invocations()(bin/offload-scanL523-545, unchanged since Phase 3 commitbdb62da): it splits invocations only on group-change or a >10-minute wall-clock gap between already-attributed turns. It has no signal for unrelated intervening activity (which never entersturnsat all), so two genuinely separate same-skill calls landing within the idle-gap window of each other get merged into one reported invocation. In the real sample, 31 turns (~3x the skill's expected ~10-call step count) fell within max 4.3-minute gaps and were merged into a single reported invocation — consistent with 3 real calls being collapsed into 1.This inflates the headline "weighted_tokens per invocation" figure quoted in filed issue bodies, without affecting the correctly-computed group totals (
weighted_tokens,offload_value,share_of_audited_spend); it can actually understateoffload_valueviamine_ngrams'min_invocations:3gate undercounting merged invocations.Separately:
cc-tokenscounting fullcache_read/cache_writeper turn (observed cache_read climbing 129,437→138,866+ across the 31-turn sample) is intentional, perdesign/spec.md§1.1/§5.2.1 ("every turn costs approximately one full context re-read"), and is causally correct foroffload_value. Not a bug.Fix
No ground-truth invocation-start marker exists in transcripts, so tightening the split heuristic risks introducing new false splits/merges. Instead, this makes the lower-bound nature of the count explicit in the data:
bin/offload-scan: addstotals.invocation_caveatto the JSON output, naming the idle-gap threshold (10 min by default).bin/audit-snapshot: threadsinvocation_caveatthroughbuild_snapshot's totals and renders it as a note bullet in the per-run issue-body template (render_run), right under the existing attribution-coverage caveat.Evidence the fix works
Added
tests/scan.test.shcheck:and
tests/snapshot.test.shcheck that the caveat propagates from a scan doc into snapshot totals and renders into the rendered.mdaudit page.Full suite, actually run:
All 7 suites (classify, filing, inventory, memory, scan, snapshot, stability) pass.
PR
kotkan/claude-plugin-inference-arbitrage#18 — merged into
main(repo has no CI checks configured, somerge_when_checks_succeed:truemerged immediately).plugin.jsonversion bumped 0.6.2 → 0.6.3.Note for #15 (separate open question about a real third measurement window) — that has its own plan already posted as a comment there; not addressed by this PR.
Original hypothesis (sidechain leakage into turn attribution) was investigated and ruled out — attribution in bin/offload-scan is correct. Real, narrower defect found: reconstruct_invocations() merges genuinely separate same-skill invocations landing within its 10-minute idle-gap window, since it has no signal for intervening unrelated activity. Fixed via PR #18 (commit
c894369, merged) — added an explicit invocation_caveat field threaded through offload-scan's output and audit-snapshot's issue-body template, so downstream filed issues state the invocation count is a lower bound instead of overstating confidence. Verified: full test suite passes on current origin/main.