fix(attribution): stop re-adding cumulative subagent tokens on every checkpoint · Entire
fix(attribution): stop re-adding cumulative subagent tokens on every checkpoint
98b4c13→main
CalculateTotalTokenUsage discovers spawned agent IDs from the full transcript (not the checkpoint-scoped slice) so subagents spawned before the current checkpoint window are still found, but it always re-reads each subagent's transcript from line 0. That makes the returned SubagentTokens a cumulative-since-session-start snapshot rather than a per-checkpoint delta, and accumulateTokenUsage was summing it across every step/checkpoint, multiplicatively overcounting subagent tokens in state.TokenUsage and state.CheckpointTokenUsage the longer a subagent stayed discoverable.
Fix: accumulateTokenUsage now replaces SubagentTokens with the latest cumulative snapshot instead of summing it, and CheckpointTokenUsage is rescoped to "since last condensation" by subtracting a new SessionState.SubagentTokensBaseline captured at each condensation reset (via the new agent/types.SubtractTokenUsage helper). This is shared plumbing (manual_commit_git.go / manual_commit_condensation.go / manual_commit_hooks.go) so it fixes both the Claude Code and Factory AI Droid extractors, which route through the same accumulation path.
Closes trail findings 019f4ddf-339 and 019f4ddf-35a.
Changes
8
cmd/entire/cli
agent
claudecode
Mtranscript.go+11/-1
factoryaidroid
Mtranscript.go+9
types
Mtoken_usage.go+32
session
Mstate.go+11
strategy
Mmanual_commit_condensation.go+14
Mmanual_commit_git.go+31/-2
Mmanual_commit_hooks.go+7
Asubagent_token_dedup_test.go+193
404 unmodified lines
405
406
407
408
408
409
410
411
412
413
414
415
416
417
418
419
420
421
404 unmodified lines
}
agentIDs := ExtractSpawnedAgentIDs(fullParsed)
// Calculate subagent token usage (skip when subagentsDir is empty to avoid reading from cwd)
// Calculate subagent token usage (skip when subagentsDir is empty to avoid reading from cwd).
// NOTE: each subagent transcript is re-read from line 0 on every call, so
// mainUsage.SubagentTokens below is a CUMULATIVE-since-session-start total,
// not a delta scoped to [startLine, end) like mainUsage's own fields.\
// Callers that invoke this repeatedly across checkpoints/turns (accumulating\
// a running total) MUST NOT sum SubagentTokens across calls or a subagent's\
// full usage gets re-added every time it stays discoverable — replace the\
// running total with the latest snapshot instead, and rescope any\
// checkpoint-window delta by subtracting a previously captured baseline.\
// See accumulateTokenUsage and SessionState.SubagentTokensBaseline in\
// cmd/entire/cli/strategy for the caller-side fix.\
if len(agentIDs) > 0 && subagentsDir != "" {\
subagentUsage := &agent.TokenUsage{}\
for agentID := range agentIDs {\
Mcmd/entire/cli/agent/claudecode/transcript.go+11/-1\
373 unmodified lines
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
373 unmodified lines
return nil, fmt.Errorf("failed to parse full transcript: %w", err)\
}
agentIDs := ExtractSpawnedAgentIDs(fullParsed)\
// NOTE: each subagent transcript is re-read from line 0 on every call\
// below, so mainUsage.SubagentTokens ends up CUMULATIVE-since-session-start\
// rather than a delta scoped to [startLine, end) like mainUsage's own\
// fields. Callers that invoke this repeatedly across checkpoints/turns\
// MUST NOT sum SubagentTokens across calls — replace the running total\
// with the latest snapshot instead, and rescope any checkpoint-window\
// delta by subtracting a previously captured baseline. See\
// accumulateTokenUsage and SessionState.SubagentTokensBaseline in\
// cmd/entire/cli/strategy for the caller-side fix (shared with Claude Code).\
if len(agentIDs) > 0 && subagentsDir != "" {\
subagentUsage := &agent.TokenUsage{}\
for agentID := range agentIDs {\
Mcmd/entire/cli/agent/factoryaidroid/transcript.go+9\
44 unmodified lines
\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
124\
125\
126\
127\
128\
129\
130\
131\
132\
133\
134\
135\
136\
137\
138\
139\
140\
141\
142\
143\
144\
145\
146\
147\
148\
149\
150\
151\
152\
153\
154\
155\
156\
157\
158\
159\
160\
161\
162\
163\
164\
165\
166\
167\
168\
169\
170\
171\
172\
173\
174\
175\
176\
177\
178\
179\
180\
181\
182\
183\
184\
185\
186\
187\
188\
189\
190\
191\
192\
193\
package strategy\
import (\
"context"\
"os"\
"path/filepath"\
"testing"\
"time"\
\
"github.com/entireio/cli/cmd/entire/cli/agent"\
"github.com/entireio/cli/cmd/entire/cli/agent/types"\
"github.com/entireio/cli/cmd/entire/cli/paths"\
"github.com/entireio/cli/cmd/entire/cli/testutil"\
"github.com/go-git/go-git/v6"\
"github.com/go-git/go-git/v6/plumbing/object"\
"github.com/stretchr/testify/require"\
)\
// TestAccumulateTokenUsage_SubagentTokensReplacedNotSummed is a focused unit\
// test on accumulateTokenUsage: CalculateTotalTokenUsage (claudecode and\
// factoryaidroid) discovers subagent IDs from the full transcript and re-reads\
// each subagent transcript from line 0 on every call, so incoming.SubagentTokens\
// is always a cumulative-since-session-start snapshot, not a per-step delta.\
// Summing that snapshot across steps (as accumulateTokenUsage does for the\
// main-agent fields) would re-add a subagent's full usage on every subsequent\
// step after it was first discovered. accumulateTokenUsage must replace\
// SubagentTokens with the latest snapshot instead.\
func TestAccumulateTokenUsage_SubagentTokensReplacedNotSummed(t *testing.T) {\
subagentSnapshot := &agent.TokenUsage{InputTokens: 500, OutputTokens: 250, APICallCount: 5}\\
\
step1 := &agent.TokenUsage{InputTokens: 100, OutputTokens: 50, APICallCount: 1, SubagentTokens: subagentSnapshot}\\
existing := accumulateTokenUsage(nil, step1)\
require.NotNil(t, existing.SubagentTokens)\
require.Equal(t, 500, existing.SubagentTokens.InputTokens)\
require.Equal(t, 250, existing.SubagentTokens.OutputTokens)\
\
// Second step within the same checkpoint window: the subagent transcript\
// hasn't changed, so CalculateTotalTokenUsage returns the SAME cumulative\
// snapshot again. Main-agent fields are per-step deltas and should sum;\
// SubagentTokens must NOT double.\
step2 := &agent.TokenUsage{InputTokens: 100, OutputTokens: 50, APICallCount: 1, SubagentTokens: subagentSnapshot}\\
existing = accumulateTokenUsage(existing, step2)\
\
require.Equal(t, 200, existing.InputTokens, "main-agent InputTokens should sum across steps")\
require.Equal(t, 100, existing.OutputTokens, "main-agent OutputTokens should sum across steps")\
require.NotNil(t, existing.SubagentTokens)\
require.Equal(t, 500, existing.SubagentTokens.InputTokens, "SubagentTokens must be replaced, not summed")\
require.Equal(t, 250, existing.SubagentTokens.OutputTokens, "SubagentTokens must be replaced, not summed")\
}\
\
// TestSaveStep_SubagentTokensNotDoubleCountedAcrossCheckpoints exercises the\
// real SaveStep path for both Claude Code and Factory AI Droid (the two\
// agents whose CalculateTotalTokenUsage implementations discover subagent IDs\
// from the full transcript per #329) and proves that a subagent discovered\
// before a checkpoint window is folded into that checkpoint's token usage\
// exactly once, not re-added on every subsequent checkpoint it remains\
// discoverable in.\
func TestSaveStep_SubagentTokensNotDoubleCountedAcrossCheckpoints(t *testing.T) {\
agentTypes := []types.AgentType{agent.AgentTypeClaudeCode, agent.AgentTypeFactoryAIDroid}\
\
for _, agentType := range agentTypes {\
t.Run(string(agentType), func(t *testing.T) {\
dir := t.TempDir()\
testutil.InitRepo(t, dir)\
repo, err := git.PlainOpen(dir)\
require.NoError(t, err)\
\
worktree, err := repo.Worktree()\
require.NoError(t, err)\
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("v1"), 0o644))\
_, err = worktree.Add("test.txt")\
require.NoError(t, err)\
_, err = worktree.Commit("Initial commit", &git.CommitOptions{\
Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},\
})\
require.NoError(t, err)\
\
// Checkpoint 1, step 1: a subagent spawned before this checkpoint's\
// window is discovered via the full-transcript scan (#329) and its\
// cumulative usage as of now is 500/250 across 5 calls.\
subagentAtCheckpoint1 := &agent.TokenUsage{InputTokens: 500, OutputTokens: 250, APICallCount: 5}\
require.NoError(t, s.SaveStep(ctx, StepContext{\
SessionID: sessionID,\
MetadataDir: metadataDir,\
MetadataDirAbs: metadataDirAbs,\
ModifiedFiles: []string{"test.txt"},\
CommitMessage: "checkpoint 1 step 1",\
AuthorName: "Test",\
AuthorEmail: "test@test.com",\
AgentType: agentType,\
TokenUsage: &agent.TokenUsage{\
InputTokens: 100, OutputTokens: 50, APICallCount: 1,\
SubagentTokens: subagentAtCheckpoint1,\
},\
}))\
\
// Checkpoint 1, step 2: same turn window, subagent transcript\
// unchanged (CalculateTotalTokenUsage would return the identical\
// cumulative snapshot again since it always re-reads from line 0).\
// Change the working tree so SaveStep sees a real diff to save.\
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("v2"), 0o644))\
require.NoError(t, s.SaveStep(ctx, StepContext{\
SessionID: sessionID,\
MetadataDir: metadataDir,\
MetadataDirAbs: metadataDirAbs,\
ModifiedFiles: []string{"test.txt"},\
CommitMessage: "checkpoint 1 step 2",\
AuthorName: "Test",\
AuthorEmail: "test@test.com",\
AgentType: agentType,\
TokenUsage: &agent.TokenUsage{\
InputTokens: 100, OutputTokens: 50, APICallCount: 1,\
SubagentTokens: subagentAtCheckpoint1,\
},\
}))\
\
state, err := s.loadSessionState(ctx, sessionID)\
require.NoError(t, err)\
require.NotNil(t, state.CheckpointTokenUsage)\
require.NotNil(t, state.CheckpointTokenUsage.SubagentTokens)\
require.Equal(t, 500, state.CheckpointTokenUsage.SubagentTokens.InputTokens,\
"subagent usage must be folded once per checkpoint window, not once per step")\
require.Equal(t, 250, state.CheckpointTokenUsage.SubagentTokens.OutputTokens)\
require.Equal(t, 200, state.CheckpointTokenUsage.InputTokens, "main-agent deltas still sum across steps")\
\
// Simulate the condensation reset that happens between checkpoints:\
// CheckpointTokenUsage is cleared and SubagentTokensBaseline snapshots\
// the cumulative subagent total counted so far, so the next\
// checkpoint's CheckpointTokenUsage.SubagentTokens is scoped to\
// "since this reset" instead of the whole session again.\
require.NoError(t, MutateSessionState(ctx, sessionID, func(st *SessionState) error {\
st.StepCount = 0\
st.CheckpointTokenUsage = nil\
if st.TokenUsage != nil {\
st.SubagentTokensBaseline = st.TokenUsage.SubagentTokens\
}\
st.CheckpointTranscriptStart = 10\
return nil\
}))\
\
// Checkpoint 2, step 1: the same subagent is still discoverable (its\
// marker line is still in the full transcript) and has grown a bit\
// more since checkpoint 1.\
subagentAtCheckpoint2 := &agent.TokenUsage{InputTokens: 620, OutputTokens: 310, APICallCount: 6}\
require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("v3"), 0o644))\
require.NoError(t, s.SaveStep(ctx, StepContext{\
SessionID: sessionID,\
MetadataDir: metadataDir,\
MetadataDirAbs: metadataDirAbs,\
ModifiedFiles: []string{"test.txt"},\
CommitMessage: "checkpoint 2 step 1",\
AuthorName: "Test",\
AuthorEmail: "test@test.com",\
AgentType: agentType,\
TokenUsage: &agent.TokenUsage{\
InputTokens: 100, OutputTokens: 50, APICallCount: 1,\
SubagentTokens: subagentAtCheckpoint2,\
},\
}))\
\
state2, err := s.loadSessionState(ctx, sessionID)\
require.NoError(t, err)\
\
// The session-wide total tracks the latest cumulative subagent\
// snapshot directly (it is already cumulative) — not the sum of the\
// checkpoint-1 and checkpoint-2 snapshots.\
require.NotNil(t, state2.TokenUsage.SubagentTokens)\
require.Equal(t, 620, state2.TokenUsage.SubagentTokens.InputTokens,\
"session-wide subagent total must be the latest cumulative snapshot, not summed across checkpoints")\
require.Equal(t, 310, state2.TokenUsage.SubagentTokens.OutputTokens)\
\
// Checkpoint 2's own CheckpointTokenUsage.SubagentTokens must be\
// rescoped to just what grew since the checkpoint-1 baseline\
// (620-500, 310-250), not the full cumulative total again.\
require.NotNil(t, state2.CheckpointTokenUsage)\
require.NotNil(t, state2.CheckpointTokenUsage.SubagentTokens)\
require.Equal(t, 120, state2.CheckpointTokenUsage.SubagentTokens.InputTokens,\
"checkpoint 2's subagent delta must exclude what was already counted in checkpoint 1")\
require.Equal(t, 60, state2.CheckpointTokenUsage.SubagentTokens.OutputTokens)\
})\
}\
}