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

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)\
        })\
    }\
}