Skip to content

fix(core): roll up subagent cost into parent session and fix fork double-counting - #43645

Open
AndyS77 wants to merge 6 commits into
anomalyco:devfrom
AndyS77:cost-subagent-rollup
Open

AndyS77 wants to merge 6 commits into
anomalyco:devfrom
AndyS77:cost-subagent-rollup

Conversation

@AndyS77

@AndyS77 AndyS77 commented Aug 20, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #39740
Closes #31032
Closes #36944

Type of change

  • Bug fix
  • New feature

What does this PR do?

Subagent session costs were not rolled up to the parent session (#39740), and forked sessions double-counted pre-fork costs (#31032, #36944). This PR fixes both issues and exposes the aggregated cost through the protocol layer so the TUI, stats, and ACP can display it.

Fork double-counting fix: When a session is forked, the cloned step-finish parts now have their cost and tokens zeroed out. The forked session starts with zero cost and only accumulates cost from its own LLM calls, not the parent's pre-fork history.

Cost rollup: Added children() and totalCost() to both V1 Session.Service and V2 SessionV2.Interface. totalCost() does a BFS walk across all descendant sessions, summing cost and tokens.

Protocol endpoint: Added session.cost and session.children to the protocol session group (/api/session/:sessionID/cost, /api/session/:sessionID/children) with handlers in both the protocol handler (packages/server) and the server handler (packages/opencode). Both SDKs were regenerated.

TUI: refreshCost effect in stream.transport.ts calls sdk.session.cost() on message.updated and bootstrap. SessionData.totalCost field; footer formatUsage uses totalCost when > 0, falls back to info.cost.

Stats + ACP: Stats uses totalCost() for top-level sessions. ACP sendUpdate uses sdk.session.cost() with totalSessionCost(messages) fallback.

How did you verify your code works?

  • 9 service-level tests (cost-rollup.test.ts): fork zeroing, own cost, child cost, nested children, multiple children
  • 10 ACP usage tests (usage.test.ts): mock updated with cost method
  • 2 HTTP E2E tests (httpapi-session.test.ts): aggregated cost across nested children (parent -> child -> grandchild, verifying 0.06 = 0.01 + 0.02 + 0.03), 404 for missing session
  • bun typecheck clean (0 source-code errors)
  • Both SDKs regenerated via bun run generate

Related PRs (used as reference)

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

AndyS77 and others added 5 commits August 20, 2026 12:50
…ble-counting

- Zero out cost/tokens on cloned step-finish parts in fork() to prevent
  double-counting pre-fork spend in forked sessions (anomalyco#31032, anomalyco#36944)
- Add Session.totalCost(sessionID) that BFS-walks descendant sessions via
  children() and aggregates cost + tokens (anomalyco#39740)
- Add GET /session/:id/cost endpoint exposing aggregated cost
- Add E2E tests: subagent cost rollup, fork with subagent history,
  nested child sessions, multiple child sessions

Closes anomalyco#39740, anomalyco#31032, anomalyco#36944
Supersedes anomalyco#25712

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
- Add totalCost field to SessionData, updated via refreshCost effect
- Footer formatUsage uses totalCost when available, falls back to info.cost
- Context % remains per-session (info.tokens) — correct for context window
- refreshCost fires on message.updated for parent session and on bootstrap
- Calls GET /session/:id/cost endpoint (requires SDK regeneration)

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
- stats: use totalCost() for top-level sessions (no parentID), skip
  child sessions to avoid double-counting
- acp: sendUpdate fetches aggregated cost via session.cost endpoint,
  falls back to totalSessionCost(messages) if unavailable
- messageLoaderFromSDK: add cost() method to MessageLoaderInterface

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
…tent SDK cost method

- Rename totalCost to subagentCost in SessionData (clarity: this is
  the aggregated child session cost, not the parent's own cost)
- Footer cost = info.cost (parent) + data.subagentCost (children)
- refreshCost uses existing session.children SDK endpoint instead of
  non-existent session.cost (requires Protocol registration for SDK gen)
- stats: catch NotFoundError from totalCost
- acp: revert to totalSessionCost(messages) until SDK supports cost
- handler: map NotFoundError to ApiNotFoundError

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
… layer

- Add session.cost and session.children endpoints to protocol session group
- Add children() and totalCost() to SessionV2 Interface + implementation
- Add handlers in protocol SessionHandler
- Regenerate both SDKs (client + legacy js)
- Switch TUI refreshCost to SDK session.cost() endpoint
- Switch ACP sendUpdate to SDK session.cost() with fallback
- Rename SessionData.subagentCost to totalCost (holds server-aggregated total)
- Footer uses totalCost when > 0, falls back to info.cost
- Add E2E tests: aggregated cost across nested children, 404 for missing session
- Update ACP usage test mock with cost method

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Found Potential Duplicates/Related PRs

#25712 - #25712

#32301 - #32301

  • Title: feat: nested sub-agent spawning (up to 5 levels) + fixes for #23091 / #13715
  • Related to subagent session handling and may have overlapping cost tracking concerns.

These are the most relevant matches. PR #25712 is particularly important as it appears to be the companion UI PR that would consume the cost aggregation API being implemented in #43645.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@greedyivan

Copy link
Copy Markdown

Reviewed against a billing-reconciled session tree (root + 3 subagents, cross-checked against the provider's invoice — context in #45417): the BFS rollup with the visited set is correct, fork zeroing works, and the test coverage (nested / multi-child / fork scenarios) is good. One real issue in the stats aggregation:

opencode stats grand total double-counts child sessions.

getAllSessions() returns every row from the session table (no parentID filter), and the aggregate does stats.totalCost += result.sessionCost per session. With this PR a top-level session contributes totalCost (own + all descendants) while its children still contribute their own session.cost — every child is counted twice in the grand total.

Numbers from my reconciled tree (root $0.168 own + children $0.0417 / $0.0395 / $0.0396):

  • correct total: $0.2887
  • stats.totalCost with this PR: 0.2887 + 0.1208 = $0.4095 (+42%)

Suggested fix: contribute 0 from child rows to the grand total when their parent is present in the aggregated set (or only aggregate top-level rows). Per-session rows can still display each session's own cost.

Two smaller notes:

@janorga

janorga commented Sep 10, 2026

Copy link
Copy Markdown

Heads-up: this PR's fork-zeroing fixes the over-count (#36944) but doesn't cover the deleted-parent case (#48306). After zeroing, an orphan fork's inherited spend becomes unrecoverable even from message-level data. Consider preserving deleted-session aggregates (rollup) or reattributing inherited cost at fork time.

When a parent session is deleted (e.g. via direct DB deletion), its children
become orphans with parentID still set. The aggregateSessionStats function
only used totalCost for top-level sessions (no parentID). Orphaned sessions
were treated as non-top-level, so their descendants' costs were lost.

Now checks if the parentID actually exists in the session set. If not,
the session is treated as top-level and totalCost (BFS) is used.

Addresses janorga's comment about deleted-parent case (anomalyco#48306).

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @Feature-dev
Scope: anomalyco#43645
@AndyS77

AndyS77 commented Sep 15, 2026

Copy link
Copy Markdown
Author

Good catch on the deleted-parent case (#48306).

The fix is in the latest push (commit b741286). In aggregateSessionStats, sessions are now treated as top-level when their parentID does not exist in the session set — this covers the orphan case where a parent row was deleted directly (bypassing session.remove, which cascades to children).

// Before:
const isTopLevel = !session.parentID

// After:
const isTopLevel = !session.parentID || !sessionIDs.has(session.parentID)

This ensures orphaned sessions use totalCost (BFS including their own descendants) instead of just session.cost, so the inherited spend remains recoverable.

Note: session.remove already cascades (deletes all descendants), so orphans only arise from direct DB deletion (migrations, crashes, manual SQL). The fix is defensive — it handles the edge case gracefully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants