cctally 1.101.0 → 1.103.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.
@@ -918,6 +918,58 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
918
918
  # Hook-tick OAuth refresh path
919
919
  # =========================================================================
920
920
 
921
+ def _hook_tick_oauth_skip_status(c, *, throttle_seconds: float) -> str | None:
922
+ """Return the automatic-refresh suppression reason under selected lock."""
923
+ now_epoch = int(time.time())
924
+ needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
925
+ obs_age = c._statusline_observe_age_seconds()
926
+ if (not needs_repair
927
+ and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
928
+ return f"skipped(statusline-fresh:{int(obs_age)}s)"
929
+ backoff_remaining = c._oauth_backoff_remaining_seconds()
930
+ if backoff_remaining > 0:
931
+ return f"skipped(backoff:{int(backoff_remaining)}s)"
932
+ age_s = c._newest_snapshot_age_seconds()
933
+ if age_s is not None and age_s < throttle_seconds:
934
+ return f"skipped(fresh:{int(age_s)}s)"
935
+ return None
936
+
937
+
938
+ def _hook_tick_parse_oauth_payload(c, api):
939
+ """Validate one OAuth response outside the selected-state lock."""
940
+ try:
941
+ seven = api["seven_day"]
942
+ seven_pct = c._normalize_percent(float(seven["utilization"]))
943
+ seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
944
+ except (TypeError, ValueError, KeyError):
945
+ return None
946
+ five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
947
+ five_pct: float | None = None
948
+ five_resets_epoch: int | None = None
949
+ if (five is not None and "utilization" in five
950
+ and isinstance(five.get("resets_at"), str)):
951
+ try:
952
+ five_pct = c._normalize_percent(float(five["utilization"]))
953
+ five_resets_epoch = _iso_to_epoch(five["resets_at"])
954
+ except (TypeError, ValueError):
955
+ five_pct = None
956
+ five_resets_epoch = None
957
+ record_args = argparse.Namespace(
958
+ percent=seven_pct,
959
+ resets_at=str(seven_resets_epoch),
960
+ five_hour_percent=five_pct,
961
+ five_hour_resets_at=(
962
+ str(five_resets_epoch) if five_resets_epoch is not None else None
963
+ ),
964
+ source="api",
965
+ )
966
+ axes = {"sevenDay", *({"fiveHour"} if five_pct is not None else set())}
967
+ parts = [f"7d={int(round(seven_pct))}"]
968
+ if five_pct is not None:
969
+ parts.append(f"5h={int(round(five_pct))}")
970
+ return record_args, axes, parts
971
+
972
+
921
973
  def _hook_tick_oauth_refresh(
922
974
  timeout_seconds: float = 5.0,
923
975
  throttle_seconds: float | None = None,
@@ -950,115 +1002,75 @@ def _hook_tick_oauth_refresh(
950
1002
  throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
951
1003
  except OauthUsageConfigError:
952
1004
  throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
953
- # The automatic writer takes the selected-state lock *before* rechecking
954
- # its suppression conditions. A concurrent publisher can otherwise make
955
- # this tick issue an unnecessary OAuth request between the initial gate
956
- # and the request itself.
957
- # #583 S2: the nudge is deferred out of the critical section. This frame
958
- # owns the lock across the OAuth fetch AND the authoritative record, so
959
- # `_authoritative_record_usage` cannot release it — the deferral has to
960
- # reach the frame that acquired it. The nudge is a loopback POST with a
961
- # multi-second timeout and this lock is an `fcntl.flock` every cctally
962
- # process contends on, so one unresponsive listener would otherwise stall
963
- # every other process's selected-state writes at status-line cadence.
964
- deferred = []
1005
+ # Check freshness under the selected-state lock, release it for the
1006
+ # potentially five-second network request, then recheck after reacquiring
1007
+ # before publication. A concurrent winner can therefore suppress this
1008
+ # response without the high-cadence hook holding the cross-process flock
1009
+ # during network I/O.
965
1010
  try:
966
1011
  with c._selected_state_lock():
967
- out = _hook_tick_oauth_refresh_locked(
968
- c,
969
- token=token,
970
- timeout_seconds=timeout_seconds,
971
- throttle_seconds=throttle_seconds,
972
- nudge_sink=lambda: deferred.append(1),
1012
+ skipped = _hook_tick_oauth_skip_status(
1013
+ c, throttle_seconds=throttle_seconds,
973
1014
  )
974
1015
  except OSError:
975
1016
  return "err(record-usage=exc)", None
976
- if deferred:
977
- c._nudge_dashboard_repaint()
978
- return out
979
-
980
-
981
- def _hook_tick_oauth_refresh_locked(
982
- c,
983
- *,
984
- token: str,
985
- timeout_seconds: float,
986
- throttle_seconds: float,
987
- nudge_sink=None,
988
- ) -> tuple[str, dict | None]:
989
- """Automatic OAuth path with the selected-state lock already held.
1017
+ if skipped is not None:
1018
+ return skipped, None
990
1019
 
991
- ``nudge_sink`` records that a dashboard nudge is warranted; the caller
992
- fires it after releasing the lock (#583 S2).
993
- """
994
- # Backfill gate: an inflight/invalid tombstone bypasses only selected-age
995
- # suppression so a later authoritative result can repair it. The normal
996
- # throttle and 429 deadline continue to bound OAuth traffic.
997
- now_epoch = int(time.time())
998
- needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
999
- obs_age = c._statusline_observe_age_seconds()
1000
- if (not needs_repair
1001
- and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
1002
- return f"skipped(statusline-fresh:{int(obs_age)}s)", None
1003
- backoff_remaining = c._oauth_backoff_remaining_seconds()
1004
- if backoff_remaining > 0:
1005
- return f"skipped(backoff:{int(backoff_remaining)}s)", None
1006
- age_s = c._newest_snapshot_age_seconds()
1007
- if age_s is not None and age_s < throttle_seconds:
1008
- return f"skipped(fresh:{int(age_s)}s)", None
1009
1020
  try:
1010
1021
  api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
1011
1022
  except RefreshUsageRateLimitError as exc:
1012
- c._oauth_backoff_register_429(
1013
- retry_after_deadline=getattr(exc, "retry_after_deadline", None),
1014
- now=time.time(),
1015
- )
1023
+ try:
1024
+ with c._selected_state_lock():
1025
+ c._oauth_backoff_register_429(
1026
+ retry_after_deadline=getattr(
1027
+ exc, "retry_after_deadline", None,
1028
+ ),
1029
+ now=time.time(),
1030
+ )
1031
+ except OSError:
1032
+ return "err(record-usage=exc)", None
1016
1033
  return "err(rate-limit)", None
1017
1034
  except RefreshUsageNetworkError:
1018
1035
  return "err(network)", None
1019
1036
  except RefreshUsageMalformedError:
1020
1037
  return "err(parse)", None
1021
- seven = api["seven_day"]
1022
- try:
1023
- seven_pct = c._normalize_percent(float(seven["utilization"]))
1024
- seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
1025
- except (TypeError, ValueError, KeyError):
1038
+
1039
+ parsed = _hook_tick_parse_oauth_payload(c, api)
1040
+ if parsed is None:
1026
1041
  return "err(parse)", None
1027
- five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
1028
- five_pct: float | None = None
1029
- five_resets_epoch: int | None = None
1030
- if (five is not None and "utilization" in five
1031
- and isinstance(five.get("resets_at"), str)):
1032
- try:
1033
- five_pct = c._normalize_percent(float(five["utilization"]))
1034
- five_resets_epoch = _iso_to_epoch(five["resets_at"])
1035
- except (TypeError, ValueError):
1036
- five_pct = None
1037
- five_resets_epoch = None
1038
- record_args = argparse.Namespace(
1039
- percent=seven_pct,
1040
- resets_at=str(seven_resets_epoch),
1041
- five_hour_percent=five_pct,
1042
- five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
1043
- source="api",
1044
- )
1045
- authoritative = c._authoritative_record_usage(
1046
- record_args,
1047
- {"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
1048
- lock_held=True,
1049
- nudge_sink=nudge_sink,
1050
- )
1051
- if authoritative.status != "ok":
1052
- reason = authoritative.reason or ""
1053
- if reason.startswith("exit "):
1054
- return f"err(record-usage={reason[5:]})", None
1042
+ record_args, axes, parts = parsed
1043
+
1044
+ # #583 S2: the nudge remains deferred until after the publication lock is
1045
+ # released. The sink contract is now enforced by the authority kernel, so
1046
+ # every lock-owning caller has to make the deferral explicit.
1047
+ deferred = []
1048
+ try:
1049
+ with c._selected_state_lock():
1050
+ skipped = _hook_tick_oauth_skip_status(
1051
+ c,
1052
+ throttle_seconds=throttle_seconds,
1053
+ )
1054
+ if skipped is not None:
1055
+ return skipped, None
1056
+ authoritative = c._authoritative_record_usage(
1057
+ record_args,
1058
+ axes,
1059
+ lock_held=True,
1060
+ nudge_sink=lambda: deferred.append(1),
1061
+ )
1062
+ if authoritative.status != "ok":
1063
+ reason = authoritative.reason or ""
1064
+ if reason.startswith("exit "):
1065
+ return f"err(record-usage={reason[5:]})", None
1066
+ return "err(record-usage=exc)", None
1067
+ # A success reaches shared backoff only after tombstones, control,
1068
+ # and freshness are authoritative under this same lock.
1069
+ c._oauth_backoff_reset()
1070
+ except OSError:
1055
1071
  return "err(record-usage=exc)", None
1056
- # Success is acknowledged to the shared OAuth backoff only after the
1057
- # authoritative writer has published tombstones, control, and freshness.
1058
- c._oauth_backoff_reset()
1059
- parts = [f"7d={int(round(seven_pct))}"]
1060
- if five_pct is not None:
1061
- parts.append(f"5h={int(round(five_pct))}")
1072
+ if deferred:
1073
+ c._nudge_dashboard_repaint()
1062
1074
  return f"ok({','.join(parts)})", api
1063
1075
 
1064
1076
 
@@ -1008,8 +1008,15 @@ def _authoritative_record_usage(
1008
1008
  selected-state writes. This frame owns the lock only on the
1009
1009
  ``lock_held=False`` path, so that is the only path that can fire the
1010
1010
  deferred nudge itself; a caller passing ``lock_held=True`` owns the lock
1011
- and must supply its own sink, or the nudge stays inside its section.
1011
+ and must supply its own sink. The fail-closed guard below enforces that
1012
+ contract before any tombstone or journal write begins.
1012
1013
  """
1014
+ if lock_held and nudge_dashboard and nudge_sink is None:
1015
+ return _AuthoritativeRecordResult(
1016
+ "record_failed",
1017
+ "lock_held authoritative record requires nudge_sink",
1018
+ )
1019
+
1013
1020
  if not lock_held:
1014
1021
  deferred = []
1015
1022
  try:
@@ -105,6 +105,7 @@ def _group_entries_into_blocks(
105
105
  dt.datetime, tuple[dt.datetime, dt.datetime]
106
106
  ] | None = None,
107
107
  now: dt.datetime | None = None,
108
+ _entry_membership: dict[int, list[UsageEntry]] | None = None,
108
109
  ) -> list[Block]:
109
110
  """Group sorted UsageEntry objects into 5-hour blocks with gap detection.
110
111
 
@@ -138,6 +139,13 @@ def _group_entries_into_blocks(
138
139
 
139
140
  `now` pins the current instant (typically via `_command_as_of()`). When
140
141
  omitted, falls back to wall clock so existing callers are unaffected.
142
+
143
+ ``_entry_membership`` is an internal detail-route seam. When supplied,
144
+ each non-gap result object's identity maps to the exact entries assigned
145
+ by this grouping pass. That ownership is stricter than a later
146
+ ``start <= timestamp < end`` filter when adjacent recorded windows overlap
147
+ after a reset shift; consumers that render per-entry samples must use the
148
+ grouping decision rather than independently reconstructing it.
141
149
  """
142
150
  if not entries:
143
151
  return []
@@ -264,6 +272,8 @@ def _group_entries_into_blocks(
264
272
  anchor="recorded",
265
273
  )
266
274
  first_entry_ts_by_block[id(blk)] = bucket_sorted[0].timestamp
275
+ if _entry_membership is not None:
276
+ _entry_membership[id(blk)] = bucket_sorted
267
277
  recorded_block_objs.append(blk)
268
278
 
269
279
  # Phase 2: Build heuristic Block objects from raw_blocks using _aggregate_block
@@ -278,6 +288,8 @@ def _group_entries_into_blocks(
278
288
  )
279
289
  if block_entries:
280
290
  first_entry_ts_by_block[id(blk)] = block_entries[0].timestamp
291
+ if _entry_membership is not None:
292
+ _entry_membership[id(blk)] = block_entries
281
293
  heuristic_block_objs.append(blk)
282
294
 
283
295
  # Merge + sort by start_time
@@ -2142,6 +2142,20 @@ def infer_codex_event_turns(
2142
2142
  return turns, current
2143
2143
 
2144
2144
 
2145
+ def fold_codex_event_turns(events) -> dict:
2146
+ """Map each prefetched physical event's ``line_offset`` to its turn.
2147
+
2148
+ The pure half of ``_file_turn_map`` (#620 S3 X3): the caller supplies the
2149
+ ordered ``codex_conversation_events`` batch and this returns
2150
+ ``{line_offset: turn_id | None}`` over the same lifecycle inference cost
2151
+ attribution and normalized prose already share. Opens no database.
2152
+ """
2153
+ materialized = list(events)
2154
+ turns, _terminal = infer_codex_event_turns(materialized)
2155
+ return {event.line_offset: turn
2156
+ for event, turn in zip(materialized, turns)}
2157
+
2158
+
2145
2159
  def _is_late_turn_anchor(
2146
2160
  record_type: str | None, payload_type: str | None, explicit_turn: str | None,
2147
2161
  ) -> bool:
@@ -1272,15 +1272,18 @@ def _tokens_union(tokens: dict) -> dict:
1272
1272
  # ── cost attribution (§5.4) ───────────────────────────────────────────────────
1273
1273
 
1274
1274
 
1275
- def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str | None]:
1276
- """Exact physical-offset → canonical logical turn for one retained file.
1277
-
1278
- Cost attribution and normalized prose use the same pure lifecycle inference,
1279
- including resumed segments whose native proof arrives on task completion.
1275
+ def _codex_file_events(conn: sqlite3.Connection, source_path: str) -> list:
1276
+ """One retained file's physical events, in offset order.
1277
+
1278
+ Split out of ``_file_turn_map`` (#620 S3 X3) so the turn attribution can
1279
+ run as a pure fold over a prefetched batch. This walk is UNBOUNDED by
1280
+ construction: it reads every event the file retains, however far the
1281
+ in-window entry sits behind that history, which is why the diagnosis
1282
+ budgets its own use of it rather than calling this directly.
1280
1283
  """
1281
1284
  from _lib_jsonl import CodexPhysicalEvent
1282
1285
 
1283
- events = [
1286
+ return [
1284
1287
  CodexPhysicalEvent(*row)
1285
1288
  for row in conn.execute(
1286
1289
  "SELECT source_path, line_offset, source_root_key, conversation_key, "
@@ -1291,8 +1294,19 @@ def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str
1291
1294
  (source_path,),
1292
1295
  )
1293
1296
  ]
1294
- turns, _terminal = kern.infer_codex_event_turns(events)
1295
- return {event.line_offset: turn for event, turn in zip(events, turns)}
1297
+
1298
+
1299
+ def _file_turn_map(conn: sqlite3.Connection, source_path: str) -> dict[int, str | None]:
1300
+ """Exact physical-offset → canonical logical turn for one retained file.
1301
+
1302
+ Cost attribution and normalized prose use the same pure lifecycle inference,
1303
+ including resumed segments whose native proof arrives on task completion.
1304
+
1305
+ Since #620 S3 this is a thin wrapper: it fetches and delegates to
1306
+ ``fold_codex_event_turns``. Its signature, return shape and every caller
1307
+ are unchanged.
1308
+ """
1309
+ return kern.fold_codex_event_turns(_codex_file_events(conn, source_path))
1296
1310
 
1297
1311
 
1298
1312
  def _attribute_costs(conn: sqlite3.Connection, conversation_key: str, effective_speed: str):
@@ -55,10 +55,13 @@ def _join_text_blocks(blocks):
55
55
  migration-011 consumer rebuilds the marker text from blocks_json to feed
56
56
  _extract_command_invocation, so this lives in the parser kernel (the one
57
57
  _cctally_cache already imports) to keep the two derivations identical."""
58
- if not blocks:
58
+ if not isinstance(blocks, list) or not blocks:
59
59
  return ""
60
- return "\n".join(b.get("text", "") or ""
61
- for b in blocks if b.get("kind") == "text")
60
+ return "\n".join(
61
+ text for b in blocks
62
+ if isinstance(b, dict) and b.get("kind") == "text"
63
+ and isinstance((text := b.get("text")), str)
64
+ )
62
65
 
63
66
 
64
67
  def _extract_command_invocation(blocks, text):