fix(oneshot): --usage-file ledger reports auxiliary LLM spend

`hermes -z --usage-file` copied only the main-loop result, so title generation,
vision, compression, web_extract and background-review calls — recorded per task
in session_model_usage — never reached the pipeline ledger the flag advertises
as "so pipelines can always account for spend". The Insights page already folds
those rows in (#23270); the ledger is now consistent with it.

- SessionDB.auxiliary_usage_by_task(session_id): per-task sums over the
  session's compression lineage (aux calls bill to the id the turn started with
  while compression mints child ids mid-turn).
- oneshot snapshots aux usage before the turn and attaches the delta after it,
  so a resumed session's earlier runs are not re-billed.
- The report gains `auxiliary` (totals + `by_task`) and
  `total_including_auxiliary`; every existing key keeps its main-loop meaning.
- The auto-title upgrade runs on a daemon thread and can still be writing when
  the turn returns: title_generator tracks in-flight upgrade threads and
  oneshot joins them (bounded) before reading — no sleep, no eager read.

Fixes #112848. Direction shared with #112852 (@KoNit-K), which folded aux into
the headline counters; this keeps them backward compatible instead.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-16 10:00:57 -07:00
committed by Teknium
parent bf28c15de4
commit 8899aeff53
5 changed files with 168 additions and 5 deletions

View File

@@ -9,6 +9,9 @@ import json
import logging
import os
import re
import threading
import time
import weakref
from contextlib import suppress
from typing import Any, Callable, Optional
@@ -19,6 +22,18 @@ from agent.message_content import flatten_message_text
logger = logging.getLogger(__name__)
# In-flight stage-2 upgrade threads. They bill their aux usage to the session from a daemon thread,
# so a process that reads the ledger right before exit (``-z --usage-file``) must be able to join
# them (bounded) instead of racing the write (#112848).
_UPGRADE_THREADS: "weakref.WeakSet[threading.Thread]" = weakref.WeakSet()
def wait_for_title_upgrades(timeout: float = 10.0) -> None:
"""Bounded join of the auto-title threads still running; never raises."""
deadline = time.monotonic() + timeout
for thread in list(_UPGRADE_THREADS):
thread.join(max(0.0, deadline - time.monotonic()))
# (task_name, exception) -> None; surfaces auxiliary failures so silent drops don't pile up as NULL titles.
FailureCallback = Callable[[str, BaseException], None]
# (title, source) -> None; source is the persisted provenance (``derived`` / ``llm``). Consumers paying a
@@ -528,9 +543,11 @@ def maybe_auto_title(
# profile whose turn this is: a bare Thread starts with an empty context and lands on the launch
# profile under multiplex, titling X's session with the default profile's model and billing its key.
from agent.memory_provider import spawn_context_thread
spawn_context_thread(
upgrade = spawn_context_thread(
auto_title_session, name="auto-title",
args=(session_db, session_id, user_message),
kwargs=dict(failure_callback=failure_callback, main_runtime=main_runtime, title_callback=title_callback,
runtime_validator=runtime_validator),
).start()
)
_UPGRADE_THREADS.add(upgrade)
upgrade.start()