cdx-manager 0.19.3 → 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.
@@ -1,29 +1,172 @@
1
- """Best-effort usage readers for interactive provider sessions."""
1
+ """Best-effort usage readers for interactive provider sessions.
2
+
3
+ Three providers report usage and one does not:
4
+
5
+ - Claude and Codex write their own JSONL transcripts, resolved by the
6
+ conversation id cdx already holds.
7
+ - Ollama writes nothing. Its counts exist only in `--verbose` terminal output,
8
+ so they are parsed out of the PTY capture cdx takes for every launch.
9
+ - Antigravity has no retrievable source. Its conversation history is
10
+ schema-less binary protobuf under `~/.gemini/antigravity-cli/conversations/`
11
+ with no `.proto` available to decode field numbers into token counts, and its
12
+ logs are plain glog with no usage-bearing line at any verbosity `agy --help`
13
+ exposes. This was investigated and is a dead end, not an oversight -- do not
14
+ spend the afternoon rediscovering it.
15
+ """
2
16
 
3
17
  import json
4
18
  import os
19
+ import re
5
20
  import time
6
21
 
22
+ from .run_usage import claude_usage_dedup_key, normalize_usage
23
+ from .status_source import _normalize_terminal_transcript
24
+
7
25
  MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
8
26
  MAX_TRANSCRIPT_CANDIDATES = 1000
9
27
  MAX_TRANSCRIPT_SCAN_SECONDS = 1
10
28
 
11
- def extract_interactive_usage(provider, auth_home, started_at=None):
12
- """Return the newest provider-native usage after an interactive session.
29
+ #: Codex repeats the conversation id at the end of each rollout filename.
30
+ _ROLLOUT_NAME = re.compile(r"^rollout-.*-([0-9a-fA-F-]{36})\.jsonl$")
31
+
32
+ #: Ollama's `--verbose` block, one per response. Captured from a real
33
+ #: `ollama run smollm2:135m --verbose` on 0.32.11:
34
+ #:
35
+ #: total duration: 1.159523666s
36
+ #: prompt eval count: 32 token(s)
37
+ #: eval count: 58 token(s)
38
+ #:
39
+ #: `prompt eval count` is the prompt actually evaluated for that response and
40
+ #: `eval count` what it generated, so a multi-turn session contributes one
41
+ #: block per turn and they sum. Ollama reports no cache and no reasoning: it is
42
+ #: a local runner with no prompt cache to read from, so those fields stay
43
+ #: absent rather than zero.
44
+ _OLLAMA_PROMPT_TOKENS = re.compile(r"^\s*prompt eval count:\s*(\d+)\s*token", re.M)
45
+ _OLLAMA_EVAL_TOKENS = re.compile(r"^\s*eval count:\s*(\d+)\s*token", re.M)
46
+
47
+ #: How the transcript that produced a usage record was found. An id match is
48
+ #: the session's own transcript by construction; a recency match is a guess
49
+ #: that happened to be the newest file, and must not read as the same thing.
50
+ MATCH_CONVERSATION_ID = "conversation_id"
51
+ MATCH_RECENCY = "recency"
52
+ #: The run's own terminal capture -- no ambiguity to resolve.
53
+ MATCH_RUN_TRANSCRIPT = "run_transcript"
54
+
55
+
56
+ def extract_interactive_usage(provider, auth_home, started_at=None, conversation_id=None,
57
+ terminal_transcript=None):
58
+ """Return this session's provider-native usage, and how it was found.
13
59
 
14
60
  Provider transcripts are not stable public APIs. A missing or changed
15
61
  record is therefore ordinary absence, never a launch failure.
62
+
63
+ Resolution is by conversation id whenever the session has one: cdx mints
64
+ Claude's itself and passes it as `--session-id`, and observes Codex's back
65
+ after a run, so the session's own transcript is addressable by name. The
66
+ mtime scan that used to decide this returned whichever file in the whole
67
+ provider home was touched most recently, which is why sessions reported
68
+ three runs and eighteen output tokens -- they were being billed for an
69
+ unrelated file.
70
+
71
+ A session with an id whose transcript cannot be found reports absence
72
+ rather than falling back to that scan: a wrong attribution is worse than a
73
+ missing one, and it is the failure this replaces.
16
74
  """
17
- path = _latest_transcript(provider, auth_home, started_at)
75
+ path, match = resolve_transcript(
76
+ provider, auth_home, started_at, conversation_id, terminal_transcript)
18
77
  if not path:
19
- return None, None
78
+ return None, None, None, None
20
79
  try:
21
80
  if os.path.getsize(path) > MAX_TRANSCRIPT_BYTES:
22
- return None, path
81
+ return None, path, match, None
23
82
  with open(path, encoding="utf-8", errors="replace") as handle:
24
- return (_codex_usage(handle) if provider == "codex" else _claude_usage(handle)), path
83
+ usage, model = _READERS.get(provider, _claude_usage)(handle)
84
+ return usage, path, match, model
25
85
  except OSError:
26
- return None, path
86
+ return None, path, match, None
87
+
88
+
89
+ def resolve_transcript(provider, auth_home, started_at=None, conversation_id=None,
90
+ terminal_transcript=None):
91
+ """The transcript belonging to this session, and how confidently."""
92
+ if provider == "ollama":
93
+ # No provider-native transcript exists to resolve. The run's own PTY
94
+ # capture is the only record, and the caller already knows its path --
95
+ # which makes this the most certain attribution of the three, not the
96
+ # least: there is no file to pick wrongly.
97
+ return (terminal_transcript, MATCH_RUN_TRANSCRIPT) if terminal_transcript else (None, None)
98
+ if not auth_home:
99
+ return None, None
100
+ if conversation_id:
101
+ path = _transcript_for_conversation(provider, auth_home, conversation_id)
102
+ if _covers_run(path, started_at) or provider != "codex":
103
+ return path, MATCH_CONVERSATION_ID
104
+ # Codex mints its conversation id itself and cdx can only read it back
105
+ # *after* the run, so at this point the session still names the
106
+ # previous conversation. Its rollout is untouched by this run, and
107
+ # differencing it yields a zero -- which is how a real codex turn came
108
+ # to report no usage at all.
109
+ #
110
+ # Falling back to recency is safe here in a way it was not for Claude:
111
+ # a Codex rollout lives under the session's own auth home, so the
112
+ # newest one touched during this run is this session's current
113
+ # conversation, not somebody else's file.
114
+ path = _latest_transcript(provider, auth_home, started_at)
115
+ return path, (MATCH_RECENCY if path else None)
116
+
117
+
118
+ def _covers_run(path, started_at):
119
+ """Whether this transcript was written during the run, so it can hold it."""
120
+ cutoff = _timestamp(started_at)
121
+ if not path:
122
+ return False
123
+ if cutoff is None:
124
+ return True
125
+ try:
126
+ return os.path.getmtime(path) >= cutoff
127
+ except OSError:
128
+ return False
129
+
130
+
131
+ def conversation_transcript(provider, auth_home, conversation_id):
132
+ """The transcript file for this exact conversation, or None if it has none.
133
+
134
+ Answers "does this conversation actually exist" without the fallbacks
135
+ `resolve_transcript` applies: a caller asking whether a resume will work
136
+ must not be told yes because some *other* conversation was found.
137
+ """
138
+ if not auth_home or not conversation_id:
139
+ return None
140
+ return _transcript_for_conversation(provider, auth_home, conversation_id)
141
+
142
+
143
+ def _transcript_for_conversation(provider, auth_home, conversation_id):
144
+ if provider == "codex":
145
+ return _codex_rollout_for(auth_home, conversation_id)
146
+ # Reuse, not reimplement: the background path already resolved a Claude
147
+ # transcript by session id, and having two rules for the same lookup is
148
+ # how they drift apart.
149
+ from .provider_background import find_session_transcript
150
+
151
+ return find_session_transcript(auth_home, conversation_id)
152
+
153
+
154
+ def _codex_rollout_for(auth_home, conversation_id):
155
+ """Codex repeats its conversation id in the rollout filename."""
156
+ root = os.path.join(auth_home, "sessions")
157
+ if not os.path.isdir(root):
158
+ return None
159
+ for directory, _subdirs, names in os.walk(root):
160
+ for name in names:
161
+ match = _ROLLOUT_NAME.match(name)
162
+ if match and match.group(1).lower() == str(conversation_id).lower():
163
+ return os.path.join(directory, name)
164
+ return None
165
+
166
+
167
+ #: Which reader parses which provider's transcript. Claude is the default
168
+ #: because its shape is what an unknown provider is most likely to resemble.
169
+ _READERS = {}
27
170
 
28
171
 
29
172
  def _latest_transcript(provider, auth_home, started_at):
@@ -37,7 +180,10 @@ def _latest_transcript(provider, auth_home, started_at):
37
180
  deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
38
181
  for directory, subdirs, names in os.walk(root):
39
182
  if time.monotonic() >= deadline:
40
- return max(candidates, default=(None, None))[1]
183
+ # An inconclusive scan is absence, not "whichever candidate we had
184
+ # reached". Returning a partial answer here is what made a wrong
185
+ # attribution indistinguishable from a real measurement.
186
+ return None
41
187
  if provider == "claude":
42
188
  subdirs[:] = [name for name in subdirs if name != "subagents"]
43
189
  for name in names:
@@ -51,10 +197,14 @@ def _latest_transcript(provider, auth_home, started_at):
51
197
  if cutoff is None or modified >= cutoff:
52
198
  candidates.append((modified, path))
53
199
  if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
54
- return max(candidates)[1]
200
+ return None
55
201
  return max(candidates, default=(None, None))[1]
56
202
 
57
203
 
204
+ _READERS.update({"codex": lambda handle: _codex_usage(handle),
205
+ "ollama": lambda handle: _ollama_usage(handle)})
206
+
207
+
58
208
  def _timestamp(value):
59
209
  if not value:
60
210
  return None
@@ -65,56 +215,96 @@ def _timestamp(value):
65
215
  return None
66
216
 
67
217
 
218
+ def _ollama_usage(handle):
219
+ """Token counts parsed out of ollama's `--verbose` blocks.
220
+
221
+ Ollama has no provider-native transcript, so the only place these numbers
222
+ exist is the terminal, and the only copy of the terminal is the PTY capture
223
+ cdx already takes through `script`. That capture is not clean text -- a
224
+ real one carries cursor show/hide sequences between individual words and
225
+ carriage returns overwriting lines in place -- so it is normalized with the
226
+ same helper the handoff reader uses before any pattern is applied.
227
+
228
+ Returns no model: ollama runs locally and its models are not in any price
229
+ table, so naming one would only invite pricing something that is free.
230
+ """
231
+ text = _normalize_terminal_transcript(handle.read())
232
+ prompts = [int(value) for value in _OLLAMA_PROMPT_TOKENS.findall(text)]
233
+ evals = [int(value) for value in _OLLAMA_EVAL_TOKENS.findall(text)]
234
+ if not prompts and not evals:
235
+ return None, None
236
+ return normalize_usage(
237
+ input_tokens=sum(prompts) if prompts else None,
238
+ output_tokens=sum(evals) if evals else None,
239
+ ), None
240
+
241
+
68
242
  def _codex_usage(handle):
243
+ """The conversation's cumulative usage, and the model serving it latest."""
69
244
  latest = None
245
+ model = None
70
246
  for line in handle:
71
247
  try:
72
248
  record = json.loads(line)
73
249
  payload = record.get("payload") or {}
250
+ if payload.get("model"):
251
+ model = payload["model"]
74
252
  usage = ((payload.get("info") or {}).get("total_token_usage") or {}) if payload.get("type") == "token_count" else {}
75
- candidate = _normalize(usage, cached_key="cached_input_tokens", reasoning_key="reasoning_output_tokens")
76
- if candidate:
77
- latest = candidate
253
+ if not isinstance(usage, dict) or not usage:
254
+ continue
255
+ # Codex counts cached tokens inside `input_tokens`; reduce to the
256
+ # uncached remainder so the field means what run_usage says it
257
+ # means. Same subset check as the headless reader: a cached count
258
+ # larger than the input count is not a subset, so that record is
259
+ # left alone rather than forced to zero.
260
+ reported_input = _number(usage.get("input_tokens"))
261
+ cache_read = _number(usage.get("cached_input_tokens"))
262
+ latest = normalize_usage(
263
+ input_tokens=reported_input - cache_read if cache_read <= reported_input else reported_input,
264
+ cache_read_tokens=cache_read,
265
+ output_tokens=_number(usage.get("output_tokens")),
266
+ reasoning_tokens=_number(usage.get("reasoning_output_tokens")) or None,
267
+ )
78
268
  except (TypeError, ValueError):
79
269
  continue
80
- return latest
270
+ return latest, model
81
271
 
82
272
 
83
273
  def _claude_usage(handle):
84
- totals = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0}
274
+ """Sum Claude's per-message usage across the transcript.
275
+
276
+ Cache creation and cache read are kept apart rather than summed on the way
277
+ in: they cost 1.25x and 0.1x of uncached input, so fusing them here would
278
+ destroy the only information a cost-aware figure needs. Claude reports no
279
+ separate reasoning count -- thinking is billed as output and is already
280
+ inside `output_tokens` -- so that field stays absent by design.
281
+ """
282
+ totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
85
283
  seen = set()
284
+ model = None
86
285
  for line in handle:
87
286
  try:
88
287
  record = json.loads(line)
89
288
  message = record.get("message") or {}
90
289
  usage = message.get("usage") or {}
91
- identifier = record.get("uuid")
92
- if record.get("type") != "assistant" or not usage or (identifier and identifier in seen):
290
+ if record.get("type") != "assistant" or not usage:
93
291
  continue
94
- if identifier:
292
+ identifier = claude_usage_dedup_key(record)
293
+ if identifier is not None:
294
+ if identifier in seen:
295
+ continue
95
296
  seen.add(identifier)
96
297
  totals["input_tokens"] += _number(usage.get("input_tokens"))
97
- totals["cached_input_tokens"] += _number(usage.get("cache_creation_input_tokens")) + _number(usage.get("cache_read_input_tokens"))
298
+ totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
299
+ totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
98
300
  totals["output_tokens"] += _number(usage.get("output_tokens"))
301
+ if message.get("model"):
302
+ model = message["model"]
99
303
  except (TypeError, ValueError):
100
304
  continue
101
305
  if not seen and not any(totals.values()):
102
- return None
103
- totals["reasoning_tokens"] = None
104
- totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"]
105
- return totals
106
-
107
-
108
- def _normalize(usage, cached_key, reasoning_key):
109
- if not isinstance(usage, dict) or not usage:
110
- return None
111
- return {
112
- "input_tokens": _number(usage.get("input_tokens")),
113
- "cached_input_tokens": _number(usage.get(cached_key)),
114
- "output_tokens": _number(usage.get("output_tokens")),
115
- "reasoning_tokens": _number(usage.get(reasoning_key)) or None,
116
- "total_tokens": _number(usage.get("total_tokens")) or None,
117
- }
306
+ return None, model
307
+ return normalize_usage(**totals), model
118
308
 
119
309
 
120
310
  def _number(value):
@@ -122,3 +312,72 @@ def _number(value):
122
312
  return max(0, int(value or 0))
123
313
  except (TypeError, ValueError):
124
314
  return 0
315
+
316
+
317
+ #: The quantities a provider actually reports. The other two fields of a usage
318
+ #: record are derived by `normalize_usage` and must never be differenced.
319
+ MEASURED_KEYS = (
320
+ "input_tokens",
321
+ "cache_creation_tokens",
322
+ "cache_read_tokens",
323
+ "output_tokens",
324
+ "reasoning_tokens",
325
+ )
326
+
327
+
328
+ def usage_delta(cumulative, previous):
329
+ """What this run added to a transcript that already held `previous`.
330
+
331
+ Both readers return a *cumulative* figure: Claude's is the sum over the
332
+ whole transcript file, Codex's is its own running total for the
333
+ conversation. Storing that on every run and summing across runs billed a
334
+ resumed session for its entire history once per resume -- one observed
335
+ session's cache read went from 2.6M to 7.6M on a single additional launch.
336
+
337
+ Returns None when the difference cannot be trusted: a transcript that
338
+ shrank, rotated, or was replaced produces a negative component, and there
339
+ is no arithmetic that recovers the truth from that. Absence is recoverable
340
+ on the next run; a fabricated number is not.
341
+ """
342
+ if not isinstance(cumulative, dict):
343
+ return None
344
+ if not isinstance(previous, dict):
345
+ return None
346
+ parts = {}
347
+ for key in MEASURED_KEYS:
348
+ now = cumulative.get(key)
349
+ before = previous.get(key)
350
+ if now is None:
351
+ parts[key] = None
352
+ continue
353
+ difference = now - (before or 0)
354
+ if difference < 0:
355
+ return None
356
+ parts[key] = difference
357
+ return normalize_usage(**parts)
358
+
359
+
360
+ def transcript_predates_run(path, started_at):
361
+ """Whether this transcript already existed before the run began.
362
+
363
+ Decides what the *first* run against a given transcript may record. A
364
+ transcript created during the run is wholly this run's work, so its total
365
+ is the run's usage. One that predates the run holds history cdx never
366
+ measured, and attributing all of it to this run would commit the very
367
+ over-count the delta exists to prevent -- so that run reports absence and
368
+ only leaves the baseline behind for its successor.
369
+ """
370
+ cutoff = _timestamp(started_at)
371
+ if cutoff is None or not path:
372
+ return False
373
+ try:
374
+ stat = os.stat(path)
375
+ except OSError:
376
+ return False
377
+ # st_birthtime where the platform records it (macOS, some BSDs); st_ctime
378
+ # elsewhere, where it means metadata change and is an upper bound on
379
+ # creation -- which errs toward "predates", the conservative answer.
380
+ created = getattr(stat, "st_birthtime", None)
381
+ if created is None:
382
+ created = stat.st_ctime
383
+ return created < cutoff
@@ -24,6 +24,7 @@ import re
24
24
  import subprocess
25
25
 
26
26
  from .config import PROVIDER_CLAUDE
27
+ from .run_usage import claude_usage_dedup_key, normalize_usage
27
28
 
28
29
  BACKGROUND_PATH_PROVIDER = "provider_native"
29
30
  BACKGROUND_PATH_CDX = "cdx_detached"
@@ -221,7 +222,10 @@ def read_transcript_outcome(path):
221
222
  outcome = {"result": None, "usage": None}
222
223
  if not path:
223
224
  return outcome
224
- totals = {"input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0, "total_tokens": 0}
225
+ totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
226
+ # This path had no de-duplication at all, so a transcript that records the
227
+ # same billed response more than once inflated the total outright.
228
+ counted = set()
225
229
  seen = False
226
230
  try:
227
231
  with open(path, encoding="utf-8", errors="replace") as handle:
@@ -247,17 +251,22 @@ def read_transcript_outcome(path):
247
251
  if text:
248
252
  outcome["result"] = text
249
253
  usage = message.get("usage")
250
- if isinstance(usage, dict):
254
+ key = claude_usage_dedup_key(record) if isinstance(usage, dict) else None
255
+ if isinstance(usage, dict) and (key is None or key not in counted):
256
+ if key is not None:
257
+ counted.add(key)
258
+ # Same split as the interactive reader, and for the same
259
+ # reason: this path used to fold the cache into input and
260
+ # emit no cached field at all, so a detached run and an
261
+ # interactive one filled the same column under opposite
262
+ # definitions.
251
263
  seen = True
252
- inp = (usage.get("input_tokens") or 0) + (usage.get("cache_creation_input_tokens") or 0) \
253
- + (usage.get("cache_read_input_tokens") or 0)
254
- out = usage.get("output_tokens") or 0
255
- totals["input_tokens"] += inp
256
- totals["output_tokens"] += out
257
- totals["total_tokens"] += inp + out
264
+ totals["input_tokens"] += usage.get("input_tokens") or 0
265
+ totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
266
+ totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
267
+ totals["output_tokens"] += usage.get("output_tokens") or 0
258
268
  except OSError:
259
269
  return outcome
260
270
  if seen:
261
- totals["reasoning_tokens"] = None
262
- outcome["usage"] = totals
271
+ outcome["usage"] = normalize_usage(**totals)
263
272
  return outcome
@@ -54,6 +54,21 @@ LOGICS_PROMPT = (
54
54
  "`logics-manager flow ...` for request/backlog/task lifecycle changes; and `logics-manager mcp ...` "
55
55
  "when an MCP surface is the right fit."
56
56
  )
57
+ #: Turns on ollama's per-response timing block, which carries the token counts.
58
+ #: Ollama writes no provider-native transcript, so without this there is
59
+ #: nothing anywhere to read and `cdx stats` shows a permanent zero.
60
+ OLLAMA_USAGE_FLAG = "--verbose"
61
+
62
+ #: Flags cdx passes on its own initiative -- not asked for by the user and not
63
+ #: derived from a permission. Declared here rather than inline so the
64
+ #: provider-flag health check can verify they exist in the installed CLI: the
65
+ #: generalized form of GitHub issue #8, where `--experimental-yolo` was mapped
66
+ #: for ollama and that CLI never had it. A flag cdx invents is exactly as
67
+ #: capable of not existing as a flag it maps.
68
+ LAUNCH_FEATURE_ARGS = {
69
+ PROVIDER_OLLAMA: [OLLAMA_USAGE_FLAG],
70
+ }
71
+
57
72
  LAUNCH_PERMISSION_ARGS = {
58
73
  PROVIDER_CLAUDE: {
59
74
  "review": ["--permission-mode", "plan"],
@@ -439,6 +454,7 @@ def _launch_config_args(session):
439
454
  # Both read their mapping from the shared table rather than inline
440
455
  # branches, so the provider-flag health check can verify what they emit
441
456
  # instead of a hand-copied restatement of it.
457
+ args += LAUNCH_FEATURE_ARGS.get(provider, [])
442
458
  if permission:
443
459
  args += LAUNCH_PERMISSION_ARGS[provider].get(permission, [])
444
460
  return args
@@ -592,6 +608,11 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
592
608
  launch = session.get("launch") or {}
593
609
  model = launch.get("model") or session["name"]
594
610
  ollama_env = {**env, "OLLAMA_NOHISTORY": "1"}
611
+ # `--verbose` arrives through _launch_config_args from
612
+ # LAUNCH_FEATURE_ARGS, so the health check and the launch read one
613
+ # table. Placement between the model and the prompt is accepted --
614
+ # verified against ollama 0.32.11, which parsed the arguments and
615
+ # failed only on the model pull.
595
616
  args = ["run", model] + _launch_config_args(session)
596
617
  if initial_prompt:
597
618
  args.append(initial_prompt)
@@ -621,18 +642,44 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
621
642
  }, capture_transcript=capture_transcript, env=env)
622
643
 
623
644
 
645
+ def _conversation_is_resumable(session, provider, conversation_id):
646
+ """Whether the named conversation exists on disk and can be resumed.
647
+
648
+ A lookup failure reads as "not found" rather than raising: this decides
649
+ which of two working strategies to use, so it must never be able to fail a
650
+ command outright.
651
+ """
652
+ if not conversation_id:
653
+ return False
654
+ try:
655
+ from .interactive_usage import conversation_transcript
656
+
657
+ return bool(conversation_transcript(provider, _get_auth_home(session), conversation_id))
658
+ except (OSError, ImportError):
659
+ return False
660
+
661
+
624
662
  def _redacted_resume_command_preview(session, cwd=None):
625
663
  capability = get_resume_capability(session, cwd=cwd)
626
664
  return capability.get("command_preview")
627
665
 
628
666
 
629
667
  def get_resume_capability(session, cwd=None):
668
+ """What resuming this session would actually do.
669
+
670
+ The stored conversation id is a claim, not a fact: it survives a launch the
671
+ provider never persisted, and cdx then promised a resume that failed with
672
+ the provider's own raw error. The conversation is therefore looked up
673
+ before it is named, and a missing one degrades to the fallback each
674
+ provider already has rather than being offered as supported.
675
+ """
630
676
  provider = session.get("provider")
631
677
  cwd = cwd or os.getcwd()
632
678
  conversation = session.get("conversation") or {}
633
679
  conversation_id = _conversation_id(session)
680
+ known = _conversation_is_resumable(session, provider, conversation_id)
634
681
  if provider == PROVIDER_CODEX:
635
- if conversation_id:
682
+ if known:
636
683
  return {
637
684
  "resumable": True,
638
685
  "provider": provider,
@@ -650,11 +697,11 @@ def get_resume_capability(session, cwd=None):
650
697
  "provenance": None,
651
698
  # Codex only reveals a conversation id once a run has written its
652
699
  # rollout, so a session that has never run legitimately has none.
653
- "reason": "no_recorded_conversation",
700
+ "reason": "conversation_not_found" if conversation_id else "no_recorded_conversation",
654
701
  "command_preview": ["codex", "resume", "--last", "--cd", cwd],
655
702
  }
656
703
  if provider == PROVIDER_CLAUDE:
657
- if conversation_id:
704
+ if known:
658
705
  return {
659
706
  "resumable": True,
660
707
  "provider": provider,
@@ -670,7 +717,7 @@ def get_resume_capability(session, cwd=None):
670
717
  "strategy": "provider_continue",
671
718
  "identity": None,
672
719
  "provenance": None,
673
- "reason": "no_recorded_conversation",
720
+ "reason": "conversation_not_found" if conversation_id else "no_recorded_conversation",
674
721
  "command_preview": ["claude", "--continue"],
675
722
  }
676
723
  return {
@@ -697,9 +744,15 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
697
744
  launch = session.get("launch") or {}
698
745
  # Resuming by id targets the conversation this session actually last
699
746
  # opened; --continue only means "the most recent one in this directory",
700
- # which another session or another cwd can silently change.
701
- conversation_id = _conversation_id(session)
702
- args = ["--resume", conversation_id] if conversation_id else ["--continue"]
747
+ # which another session or another cwd can silently change. So the id
748
+ # is preferred -- but only when the capability confirmed the
749
+ # conversation exists, since naming one the provider never wrote just
750
+ # trades a vaguer resume for a failed one.
751
+ args = (
752
+ ["--resume", capability["identity"]]
753
+ if capability["strategy"] == "provider_conversation_id"
754
+ else ["--continue"]
755
+ )
703
756
  args += ["--name", session["name"]]
704
757
  if launch.get("model"):
705
758
  args += ["--model", _claude_cli_model(launch["model"])]
@@ -723,8 +776,13 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
723
776
  }, capture_transcript=capture_transcript, env=env)
724
777
 
725
778
  launch = session.get("launch") or {}
726
- conversation_id = _conversation_id(session)
727
- args = ["resume", conversation_id] if conversation_id else ["resume", "--last"]
779
+ # Same rule as the Claude branch: name the conversation only when the
780
+ # capability confirmed its rollout exists, else fall back to --last.
781
+ args = (
782
+ ["resume", capability["identity"]]
783
+ if capability["strategy"] == "provider_conversation_id"
784
+ else ["resume", "--last"]
785
+ )
728
786
  args += ["--cd", cwd]
729
787
  if launch.get("model"):
730
788
  args += ["--model", launch["model"]]
package/src/repair.py CHANGED
@@ -41,6 +41,16 @@ def repair_health(service, base_dir, env=None, dry_run=True, force=False):
41
41
  detail,
42
42
  "skipped",
43
43
  ))
44
+ usage = service["drop_unvouched_usage"](dry_run=dry_run)
45
+ if usage["dropped"]:
46
+ actions.append(_action(
47
+ "drop_unvouched_usage",
48
+ f"drop token usage from {usage['dropped']} launch record"
49
+ f"{'s' if usage['dropped'] != 1 else ''} written before the field "
50
+ f"definition existed (of {usage['scanned']} scanned)",
51
+ None,
52
+ None if dry_run else "applied",
53
+ ))
44
54
  return {
45
55
  "dry_run": dry_run,
46
56
  "force": force,
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
7
7
 
8
8
  from .errors import CdxError
9
9
  from .fs_utils import atomic_write
10
+ from .run_usage import coerce_usage
10
11
 
11
12
 
12
13
  def utc_now_iso():
@@ -315,7 +316,10 @@ class RunRegistry:
315
316
  "stderr_path": run_info.get("stderr_path"),
316
317
  }
317
318
  if final_payload:
318
- run["usage"] = final_payload.get("usage")
319
+ # Normalize on the way in, not on the way out: this record is
320
+ # what `cdx run-status` and `cdx run-report` publish, and it
321
+ # used to store whichever shape the producing reader emitted.
322
+ run["usage"] = coerce_usage(final_payload.get("usage"))
319
323
  run["final_payload"] = final_payload
320
324
  if error:
321
325
  run["error"] = error