cdx-manager 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run_usage.py CHANGED
@@ -1,6 +1,58 @@
1
+ """What a cdx usage record means, and the only place that decides it.
2
+
3
+ Three code paths read provider usage -- this module for headless stdout,
4
+ `interactive_usage` for a terminal launch, `provider_background` for a native
5
+ background close-out -- and each used to compute the fields its own way. They
6
+ disagreed in every direction at once: one dropped cache from the total, one
7
+ counted it twice, one omitted the cached field entirely. All three wrote into
8
+ the same launch history and the same `cdx stats` column, so the table mixed
9
+ definitions without saying so.
10
+
11
+ The definitions below are therefore normative, not a suggestion:
12
+
13
+ input_tokens Uncached input only. Claude reports it this way
14
+ natively; Codex does not, so its cache-inclusive count
15
+ is reduced here rather than left for a reader to guess.
16
+ cache_creation_tokens Tokens written to the provider's prompt cache.
17
+ cache_read_tokens Tokens served from it.
18
+ cached_input_tokens cache_creation + cache_read. Kept because it is a
19
+ published field of `cdx run --json`, and because the
20
+ stats table shows one CACHE column. It is a derived
21
+ sum, never a source.
22
+ output_tokens Generated tokens.
23
+ reasoning_tokens Reasoning counted separately by the provider. Codex
24
+ reports one; Claude does not and never will -- it bills
25
+ thinking as output, so those tokens are already inside
26
+ output_tokens. An empty REASON column for Claude is
27
+ correct, not a gap to fill.
28
+ total_tokens input + cache_creation + cache_read + output. Cache is
29
+ the bulk of real consumption, so a total that excludes
30
+ it is not a slightly-wrong total, it is a different
31
+ quantity.
32
+
33
+ Cache creation and cache read are separate fields because they cost very
34
+ differently -- 1.25x and 0.1x of uncached input -- and a weighted figure cannot
35
+ recover the split once they are summed.
36
+
37
+ Absence stays absent: `None` means "not reported", which is not zero. Only
38
+ `normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
39
+ caller can invent a fourth arithmetic.
40
+ """
41
+
1
42
  import json
2
43
 
3
- USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
44
+ #: Order matters only for readability; every consumer addresses these by name.
45
+ USAGE_KEYS = (
46
+ "input_tokens",
47
+ "cached_input_tokens",
48
+ "cache_creation_tokens",
49
+ "cache_read_tokens",
50
+ "output_tokens",
51
+ "reasoning_tokens",
52
+ "total_tokens",
53
+ )
54
+ #: The two fields `normalize_usage` derives. Never read them from a provider.
55
+ DERIVED_USAGE_KEYS = ("cached_input_tokens", "total_tokens")
4
56
  SUPPORTED_PROVIDERS = {"claude", "codex"}
5
57
 
6
58
 
@@ -8,6 +60,275 @@ def empty_usage():
8
60
  return {key: None for key in USAGE_KEYS}
9
61
 
10
62
 
63
+ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
64
+ output_tokens=None, reasoning_tokens=None):
65
+ """Build a usage record from the four measured quantities.
66
+
67
+ The derived fields are computed here and only here. A record whose parts are
68
+ all absent stays fully absent rather than becoming a row of zeros: a session
69
+ cdx could not measure must not read as a session that spent nothing.
70
+ """
71
+ parts = {
72
+ "input_tokens": _non_negative(input_tokens),
73
+ "cache_creation_tokens": _non_negative(cache_creation_tokens),
74
+ "cache_read_tokens": _non_negative(cache_read_tokens),
75
+ "output_tokens": _non_negative(output_tokens),
76
+ "reasoning_tokens": _non_negative(reasoning_tokens),
77
+ }
78
+ if all(value is None for value in parts.values()):
79
+ return empty_usage()
80
+ cached = _sum_present(parts["cache_creation_tokens"], parts["cache_read_tokens"])
81
+ total = _sum_present(
82
+ parts["input_tokens"],
83
+ parts["cache_creation_tokens"],
84
+ parts["cache_read_tokens"],
85
+ parts["output_tokens"],
86
+ )
87
+ return {**parts, "cached_input_tokens": cached, "total_tokens": total}
88
+
89
+
90
+ #: What a cached token costs relative to one uncached input token.
91
+ #:
92
+ #: Verified against both vendors on 2026-08-14 and identical on each: a cache
93
+ #: read is a 90% discount on the input rate, and a cache write is 1.25x it.
94
+ #: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
95
+ #: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
96
+ #: are a small share of a cache-heavy session, so that error is bounded --
97
+ #: stated here rather than hidden behind a single averaged number.
98
+ CACHE_READ_MULTIPLIER = 0.1
99
+ CACHE_WRITE_MULTIPLIER = 1.25
100
+
101
+ #: What an output token costs relative to an uncached input one, when the model
102
+ #: is unknown. Anthropic bills 5x and OpenAI 6x, so an unknown model is priced
103
+ #: at the lower of the two: understating a cost cdx is guessing at is the safer
104
+ #: direction.
105
+ DEFAULT_OUTPUT_MULTIPLIER = 5.0
106
+
107
+
108
+ def output_multiplier(model, prices=None):
109
+ """This model's output-to-input price ratio."""
110
+ entry = (prices if prices is not None else token_prices()[0]).get(model or "")
111
+ if not entry:
112
+ return DEFAULT_OUTPUT_MULTIPLIER
113
+ return entry["output"] / entry["input"]
114
+
115
+
116
+ def weighted_usage(usage, model=None, prices=None):
117
+ """Consumption in uncached-input-equivalent tokens, or None if unknown.
118
+
119
+ A raw token total ranks a cache read equal to an output token worth fifty
120
+ times more, and cache reads are typically the overwhelming majority of
121
+ tokens -- so a raw total is very nearly a measure of cache reads alone.
122
+ That is not the ranking anyone reading `cdx stats` is looking for.
123
+
124
+ The output ratio comes from the model that served the run, because it is
125
+ the one multiplier that differs between vendors. The cache multipliers do
126
+ not, so they are constants.
127
+
128
+ `reasoning_tokens` is deliberately excluded: Codex reports it as a subset
129
+ of its output, and counting it would bill those tokens twice.
130
+ """
131
+ if not isinstance(usage, dict):
132
+ return None
133
+ weights = {
134
+ "input_tokens": 1.0,
135
+ "cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
136
+ "cache_read_tokens": CACHE_READ_MULTIPLIER,
137
+ "output_tokens": output_multiplier(model, prices),
138
+ }
139
+ present = [
140
+ weight * usage[key]
141
+ for key, weight in weights.items()
142
+ if usage.get(key) is not None
143
+ ]
144
+ if not present:
145
+ return None
146
+ return int(round(sum(present)))
147
+
148
+
149
+ #: List prices in dollars per million tokens: uncached input and output. The
150
+ #: cache classes are derived from the input rate by the multipliers above, an
151
+ #: invariant that holds on both vendors, so two numbers price a model.
152
+ #:
153
+ #: This is the one genuinely perishable input in cdx's usage accounting.
154
+ #: Override with CDX_TOKEN_PRICES (JSON: model -> {"input": x, "output": y})
155
+ #: rather than editing code, and treat an unknown model as unpriced instead of
156
+ #: assuming a tier. `scripts/check_token_prices.py` re-checks this table
157
+ #: against published pricing and a staleness test fails when nobody has;
158
+ #: `docs/token-prices-runbook.md` records where the numbers live and which
159
+ #: vendor pages refuse to be read.
160
+ #:
161
+ #: Anthropic figures come from the vendor's own reference. OpenAI figures were
162
+ #: taken from third-party aggregators on 2026-08-14 because openai.com refuses
163
+ #: automated fetches; four sources agreed on $2.50/$15 for Terra and one
164
+ #: outlier said $2/$12, so these carry more doubt than the Anthropic rows.
165
+ #:
166
+ #: Not modelled: OpenAI's long-context tier, which roughly doubles both rates
167
+ #: above a 272K-token request. cdx records tokens per run, not per request, so
168
+ #: it cannot tell which requests crossed that line -- long-context Codex work
169
+ #: is therefore under-costed here, and knowingly so.
170
+ DEFAULT_TOKEN_PRICES = {
171
+ # Anthropic
172
+ "claude-fable-5": {"input": 10.0, "output": 50.0},
173
+ "claude-mythos-5": {"input": 10.0, "output": 50.0},
174
+ "claude-opus-5": {"input": 5.0, "output": 25.0},
175
+ "claude-opus-4-8": {"input": 5.0, "output": 25.0},
176
+ "claude-opus-4-7": {"input": 5.0, "output": 25.0},
177
+ "claude-opus-4-6": {"input": 5.0, "output": 25.0},
178
+ "claude-sonnet-5": {"input": 3.0, "output": 15.0},
179
+ "claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
180
+ "claude-haiku-4-5": {"input": 1.0, "output": 5.0},
181
+ # OpenAI
182
+ "gpt-5.6-sol": {"input": 5.0, "output": 30.0},
183
+ "gpt-5.6-terra": {"input": 2.5, "output": 15.0},
184
+ "gpt-5.6-luna": {"input": 1.0, "output": 6.0},
185
+ }
186
+ TOKEN_PRICES_REVIEWED = "2026-08-14"
187
+ #: How long a review stays fresh before the staleness test asks for another.
188
+ TOKEN_PRICES_MAX_AGE_DAYS = 90
189
+ TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
190
+
191
+
192
+ def token_prices(env=None):
193
+ """The price table in force, and where it came from."""
194
+ import os
195
+
196
+ environment = env if env is not None else os.environ
197
+ raw = environment.get(TOKEN_PRICES_ENV)
198
+ if raw:
199
+ try:
200
+ overrides = json.loads(raw)
201
+ except ValueError:
202
+ return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED} ({TOKEN_PRICES_ENV} ignored: not JSON)"
203
+ if isinstance(overrides, dict):
204
+ merged = {**DEFAULT_TOKEN_PRICES}
205
+ for name, entry in overrides.items():
206
+ if isinstance(entry, dict) and "input" in entry and "output" in entry:
207
+ merged[str(name)] = {
208
+ "input": float(entry["input"]), "output": float(entry["output"])}
209
+ return merged, TOKEN_PRICES_ENV
210
+ return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED}"
211
+
212
+
213
+ def estimate_cost(usage, model, prices=None):
214
+ """Dollars this usage would list at, or None when it cannot be priced.
215
+
216
+ Each token class is priced directly rather than through the weighted
217
+ figure. That shortcut was only correct while every model shared one
218
+ output-to-input ratio, and it does not: Anthropic bills 5x, OpenAI 6x, so
219
+ it under-costed Codex output by a fifth.
220
+
221
+ ponytail: a run is priced at one model -- the one serving its most recent
222
+ record -- rather than split per model. A session that switches models
223
+ mid-run is priced at the newer one. Upgrade path if that matters: have the
224
+ readers return a per-model breakdown and difference each model separately.
225
+
226
+ An unknown or absent model yields None. Pricing it at a default tier would
227
+ turn "cdx does not know" into a number someone might believe.
228
+ """
229
+ if not model or not isinstance(usage, dict):
230
+ return None
231
+ rate = (prices if prices is not None else token_prices()[0]).get(model)
232
+ if rate is None:
233
+ return None
234
+ per_million = {
235
+ "input_tokens": rate["input"],
236
+ "cache_read_tokens": rate["input"] * CACHE_READ_MULTIPLIER,
237
+ "cache_creation_tokens": rate["input"] * CACHE_WRITE_MULTIPLIER,
238
+ "output_tokens": rate["output"],
239
+ }
240
+ present = [
241
+ price * usage[key] for key, price in per_million.items()
242
+ if usage.get(key) is not None
243
+ ]
244
+ if not present:
245
+ return None
246
+ return sum(present) / 1_000_000
247
+
248
+
249
+ def claude_usage_dedup_key(record):
250
+ """What identifies one *billed* Claude API response in a transcript.
251
+
252
+ Not the record's `uuid`. A Claude Code transcript writes the same assistant
253
+ response under several uuids -- measured on one real 2741-row transcript:
254
+ 2741 distinct uuids for 1765 distinct responses, so 36% of the rows were
255
+ repeats of a call the account paid for once. Summing per uuid inflated that
256
+ file's tokens by about 1.55x against an independent reader of the same
257
+ bytes.
258
+
259
+ The billed unit is the API response, so the key is the message id paired
260
+ with the request that produced it. `uuid` remains the fallback for records
261
+ that carry neither, which is the older transcript shape.
262
+
263
+ Returns None when the record carries no identity at all. That means "cannot
264
+ prove this is a repeat", and the caller must count it: two identity-less
265
+ records collapsing into one would discard usage that was really spent,
266
+ which is a worse error than counting a duplicate.
267
+ """
268
+ message = record.get("message") or {}
269
+ message_id = message.get("id")
270
+ request_id = record.get("requestId")
271
+ if message_id and request_id:
272
+ return ("api", message_id, request_id)
273
+ if message_id:
274
+ return ("message", message_id)
275
+ uuid = record.get("uuid")
276
+ return ("uuid", uuid) if uuid else None
277
+
278
+
279
+ def is_vouched(usage):
280
+ """Whether this record was written under the current field definition.
281
+
282
+ Records that predate it are not imprecise, they are fictitious: the reader
283
+ picked whichever transcript had the newest mtime and re-read all of it on
284
+ every launch. They are filtered out of aggregates rather than displayed,
285
+ because a wrong figure carries the same authority as a right one.
286
+
287
+ The marker is the presence of `cache_creation_tokens`, which
288
+ `normalize_usage` always emits and nothing before it did. Deliberately not
289
+ `usage_cumulative`: headless runs legitimately have none.
290
+ """
291
+ return isinstance(usage, dict) and "cache_creation_tokens" in usage
292
+
293
+
294
+ def coerce_usage(usage):
295
+ """Bring an already-built usage record onto the canonical shape.
296
+
297
+ For boundaries that receive a record rather than measure one -- the run
298
+ registry, which publishes what it stores. Missing fields become absent
299
+ rather than zero, and the derived figures are recomputed from the parts, so
300
+ a record built before this definition existed cannot carry a stale total
301
+ into the JSON surfaces.
302
+ """
303
+ if not isinstance(usage, dict):
304
+ return None
305
+ record = normalize_usage(
306
+ input_tokens=usage.get("input_tokens"),
307
+ cache_creation_tokens=usage.get("cache_creation_tokens"),
308
+ cache_read_tokens=usage.get("cache_read_tokens"),
309
+ output_tokens=usage.get("output_tokens"),
310
+ reasoning_tokens=usage.get("reasoning_tokens"),
311
+ )
312
+ if record["cached_input_tokens"] is None:
313
+ # A legacy record carrying only the fused figure: keep it visible rather
314
+ # than dropping a number someone already recorded, but leave the split
315
+ # absent because it genuinely is not recoverable.
316
+ record["cached_input_tokens"] = _first_int(usage.get("cached_input_tokens"))
317
+ return record
318
+
319
+
320
+ def _non_negative(value):
321
+ parsed = _first_int(value)
322
+ return parsed
323
+
324
+
325
+ def _sum_present(*values):
326
+ present = [value for value in values if value is not None]
327
+ if not present:
328
+ return None
329
+ return sum(present)
330
+
331
+
11
332
  def extract_run_usage(provider, stdout_path):
12
333
  if not stdout_path or not provider:
13
334
  return empty_usage()
@@ -79,33 +400,67 @@ def _find_usage(value):
79
400
 
80
401
 
81
402
  def _usage_from_dict(value):
403
+ """Map one provider usage object onto the canonical fields.
404
+
405
+ This used to fold the cache counts into `input_tokens` *and* report them
406
+ again as `cached_input_tokens`, so IN and CACHE both counted the same
407
+ tokens. The fix is not arithmetic in this function: it is that the split
408
+ parts go to `normalize_usage`, which owns every derived figure.
409
+ """
82
410
  usage = value.get("usage") if isinstance(value.get("usage"), dict) else value
83
411
  if not isinstance(usage, dict):
84
412
  return empty_usage()
85
413
 
86
- input_tokens = _int_value(
87
- usage.get("input_tokens"),
88
- usage.get("prompt_tokens"),
89
- usage.get("cache_creation_input_tokens"),
90
- usage.get("cache_read_input_tokens"),
91
- )
92
- output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
93
- reasoning_tokens = _int_value(
94
- usage.get("reasoning_tokens"),
95
- usage.get("reasoning_output_tokens"),
96
- _nested_int(usage, "output_tokens_details", "reasoning_tokens"),
97
- _nested_int(usage, "completion_tokens_details", "reasoning_tokens"),
414
+ cache_creation = _first_int(usage.get("cache_creation_input_tokens"))
415
+ cache_read = _first_int(usage.get("cache_read_input_tokens"))
416
+ if cache_creation is None and cache_read is None:
417
+ # Codex reports one cached figure with no creation/read split; it is a
418
+ # read by construction, since nothing else could have written it.
419
+ cache_read = _first_int(usage.get("cached_input_tokens"))
420
+
421
+ input_tokens = _int_value(usage.get("input_tokens"), usage.get("prompt_tokens"))
422
+ input_tokens = _uncached_input(input_tokens, cache_read, usage)
423
+
424
+ return normalize_usage(
425
+ input_tokens=input_tokens,
426
+ cache_creation_tokens=cache_creation,
427
+ cache_read_tokens=cache_read,
428
+ output_tokens=_int_value(usage.get("output_tokens"), usage.get("completion_tokens")),
429
+ reasoning_tokens=_int_value(
430
+ usage.get("reasoning_tokens"),
431
+ usage.get("reasoning_output_tokens"),
432
+ _nested_int(usage, "output_tokens_details", "reasoning_tokens"),
433
+ _nested_int(usage, "completion_tokens_details", "reasoning_tokens"),
434
+ ),
98
435
  )
99
- total_tokens = _first_int(usage.get("total_tokens"))
100
- if total_tokens is None and input_tokens is not None and output_tokens is not None:
101
- total_tokens = input_tokens + output_tokens
102
-
103
- return {
104
- "input_tokens": input_tokens,
105
- "output_tokens": output_tokens,
106
- "reasoning_tokens": reasoning_tokens,
107
- "total_tokens": total_tokens,
108
- }
436
+
437
+
438
+ def _uncached_input(input_tokens, cache_read, usage):
439
+ """Reduce a cache-inclusive input count to its uncached remainder.
440
+
441
+ Two dialects reach this function. Anthropic reports `input_tokens` as the
442
+ uncached remainder already and carries the `cache_*_input_tokens` pair.
443
+ OpenAI-shaped records -- Codex among them -- report a bare
444
+ `cached_input_tokens` that is a *subset* of `input_tokens`, so leaving it
445
+ alone would count those tokens in both IN and CACHE.
446
+
447
+ The subset relationship is the assumption, and it is checked rather than
448
+ trusted: a record where the cached count exceeds the input count cannot be
449
+ a subset, so that record is left alone instead of being forced to zero.
450
+ Guessing wrong in that direction would silently erase real input tokens,
451
+ which is the failure this whole definition exists to stop.
452
+ """
453
+ if input_tokens is None or not cache_read:
454
+ return input_tokens
455
+ if usage.get("cache_creation_input_tokens") is not None:
456
+ return input_tokens
457
+ if usage.get("cache_read_input_tokens") is not None:
458
+ return input_tokens
459
+ if usage.get("cached_input_tokens") is None:
460
+ return input_tokens
461
+ if cache_read > input_tokens:
462
+ return input_tokens
463
+ return input_tokens - cache_read
109
464
 
110
465
 
111
466
  def _nested_int(value, parent, child):
@@ -789,7 +789,15 @@ def finish_session_runtime(store, name, run_id=None, payload=None):
789
789
  return outcome.get("runtime")
790
790
 
791
791
 
792
- def launch_session(store, name):
792
+ def launch_session(store, name, resume=False):
793
+ """Mark a session launched, minting a new conversation id when appropriate.
794
+
795
+ `resume` exists because minting is exactly wrong there: a resume must carry
796
+ the conversation it is resuming. Without this, `cdx <session> --resume`
797
+ minted a fresh id, stored it, and then asked the provider to resume a
798
+ conversation that had never existed -- "No conversation found with session
799
+ ID" on every attempt, for Claude.
800
+ """
793
801
  session = store["get_session"](name)
794
802
  if not session:
795
803
  raise CdxError(f"Unknown session: {name}")
@@ -806,7 +814,7 @@ def launch_session(store, name):
806
814
 
807
815
  def session_updater(s):
808
816
  updated = {**s, "updatedAt": now, "lastLaunchedAt": now}
809
- conversation = _conversation_identity_for_launch(s)
817
+ conversation = None if resume else _conversation_identity_for_launch(s)
810
818
  if conversation:
811
819
  updated["conversation"] = conversation
812
820
  return updated
@@ -912,6 +920,7 @@ def create_session_service(options=None):
912
920
  "record_status": partial(record_status, store),
913
921
  "record_launch_history": partial(record_launch_history, store),
914
922
  "get_launch_history": partial(get_launch_history, store),
923
+ "drop_unvouched_usage": store["drop_unvouched_usage"],
915
924
  "update_auth_state": partial(update_auth_state, store),
916
925
  "get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
917
926
  "get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
@@ -6,6 +6,7 @@ from contextlib import contextmanager
6
6
  from pathlib import Path
7
7
 
8
8
  from .errors import CdxError
9
+ from .fs_utils import atomic_write
9
10
 
10
11
 
11
12
  def _ensure_dir(path):
@@ -280,6 +281,58 @@ def create_session_store(base_dir):
280
281
  os.fsync(handle.fileno())
281
282
  _fsync_directory(state_dir)
282
283
 
284
+ def drop_unvouched_usage(dry_run=True):
285
+ """Strip usage from launch-history records written before the definition.
286
+
287
+ Those records are not imprecise, they are fictitious: the reader picked
288
+ whichever transcript had the newest mtime and re-read all of it on
289
+ every launch, so one session reported 206.6M cached tokens for a period
290
+ in which its own conversation consumed 33,608.
291
+
292
+ They cannot be recomputed -- a run's true usage is the increment its
293
+ transcript gained while it ran, and nothing recorded the transcript's
294
+ state at that moment. So the figures go and everything else stays: a
295
+ run that happened still happened, with its duration, status and time.
296
+
297
+ The discriminator is the absence of `cache_creation_tokens`, which
298
+ `normalize_usage` always emits and nothing before it did. Not the
299
+ absence of `usage_cumulative`: headless runs legitimately have none,
300
+ and keying on it would drop correct records.
301
+ """
302
+ with _file_lock(lock_file):
303
+ try:
304
+ with open(launch_history_file, encoding="utf-8") as handle:
305
+ lines = handle.readlines()
306
+ except FileNotFoundError:
307
+ return {"scanned": 0, "dropped": 0, "dry_run": dry_run}
308
+ scanned = dropped = 0
309
+ rewritten = []
310
+ for line in lines:
311
+ stripped = line.strip()
312
+ if not stripped:
313
+ continue
314
+ try:
315
+ entry = json.loads(stripped)
316
+ except ValueError:
317
+ # Unreadable lines are left exactly as they are: this
318
+ # removes figures, it does not tidy the file.
319
+ rewritten.append(line)
320
+ continue
321
+ scanned += 1
322
+ usage = entry.get("usage")
323
+ if isinstance(usage, dict) and "cache_creation_tokens" not in usage:
324
+ dropped += 1
325
+ entry.pop("usage", None)
326
+ entry.pop("usage_cumulative", None)
327
+ entry["usage_dropped_reason"] = "recorded_before_field_definition"
328
+ line = json.dumps(entry, separators=(",", ":")) + "\n"
329
+ rewritten.append(line)
330
+ if not dry_run and dropped:
331
+ _ensure_dir(state_dir)
332
+ atomic_write(launch_history_file, "".join(rewritten))
333
+ _fsync_directory(state_dir)
334
+ return {"scanned": scanned, "dropped": dropped, "dry_run": dry_run}
335
+
283
336
  def list_launch_history(session_name=None, limit=20):
284
337
  with _file_lock(lock_file):
285
338
  try:
@@ -315,5 +368,6 @@ def create_session_store(base_dir):
315
368
  "write_session_state": write_session_state,
316
369
  "update_session_state": update_session_state,
317
370
  "append_launch_history": append_launch_history,
371
+ "drop_unvouched_usage": drop_unvouched_usage,
318
372
  "list_launch_history": list_launch_history,
319
373
  }