cctally 1.72.0 → 1.73.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/CHANGELOG.md +11 -0
- package/bin/_cctally_cache.py +245 -50
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +500 -28
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +269 -7
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +4 -1
package/bin/_cctally_doctor.py
CHANGED
|
@@ -42,6 +42,54 @@ def _cctally():
|
|
|
42
42
|
return sys.modules["cctally"]
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _gather_statusline_pipeline(c, *, now_utc: dt.datetime) -> dict:
|
|
46
|
+
"""Read the #318 statusline pipeline without creating or pruning files."""
|
|
47
|
+
now_epoch = int(now_utc.timestamp())
|
|
48
|
+
result = {
|
|
49
|
+
"transport_age_seconds": None,
|
|
50
|
+
"selected_age_seconds": None,
|
|
51
|
+
"active_candidate_count": 0,
|
|
52
|
+
"control_db_agrees": None,
|
|
53
|
+
"tombstones": {"fiveHour": "absent", "sevenDay": "absent"},
|
|
54
|
+
}
|
|
55
|
+
try:
|
|
56
|
+
result["transport_age_seconds"] = c._statusline_transport_age_seconds()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
result["selected_age_seconds"] = c._statusline_observe_age_seconds()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
result["active_candidate_count"] = len(
|
|
65
|
+
c._scan_active_candidate_spool(now_epoch=now_epoch)
|
|
66
|
+
)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
try:
|
|
70
|
+
result["control_db_agrees"] = c._statusline_control_db_agreement(
|
|
71
|
+
now_epoch=now_epoch
|
|
72
|
+
)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
for axis, path in (
|
|
76
|
+
("fiveHour", _cctally_core.STATUSLINE_AUTHORITATIVE_5H_PATH),
|
|
77
|
+
("sevenDay", _cctally_core.STATUSLINE_AUTHORITATIVE_7D_PATH),
|
|
78
|
+
):
|
|
79
|
+
try:
|
|
80
|
+
if not path.exists():
|
|
81
|
+
continue
|
|
82
|
+
tombstone = c._read_tombstone(
|
|
83
|
+
axis, now_epoch=now_epoch, fail_closed=False
|
|
84
|
+
)
|
|
85
|
+
result["tombstones"][axis] = (
|
|
86
|
+
tombstone.state if tombstone is not None else "invalid"
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
result["tombstones"][axis] = "invalid"
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
45
93
|
def _codex_lifecycle_activity_24h(
|
|
46
94
|
*, root_keys: set[str], now_utc: dt.datetime,
|
|
47
95
|
) -> dict[str, dict]:
|
|
@@ -357,6 +405,16 @@ def doctor_gather_state(
|
|
|
357
405
|
except Exception:
|
|
358
406
|
pass
|
|
359
407
|
|
|
408
|
+
# ── Statusline candidate arbitration (#318) ──────────────────────
|
|
409
|
+
# This inspection is deliberately independent of SQLite mutation: marker
|
|
410
|
+
# mtime, candidate/control files, and tombstones are all read fail-soft.
|
|
411
|
+
# In particular it uses the scan-only candidate helper, never the reducer
|
|
412
|
+
# loader that prunes expired or malformed spool files.
|
|
413
|
+
try:
|
|
414
|
+
statusline_pipeline = _gather_statusline_pipeline(c, now_utc=now_utc)
|
|
415
|
+
except Exception:
|
|
416
|
+
statusline_pipeline = None
|
|
417
|
+
|
|
360
418
|
# Conversation-sessions rollup consistency (#217 S1 / U9). Two cheap COUNTs
|
|
361
419
|
# (graceful None on a missing table / unreadable DB) + an in-progress signal
|
|
362
420
|
# so a transient mid-sync mismatch never WARNs. The in-progress signal is a
|
|
@@ -787,6 +845,7 @@ def doctor_gather_state(
|
|
|
787
845
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
|
788
846
|
# #311: precomputed statusLine.refreshInterval classification.
|
|
789
847
|
statusline_refresh_state=statusline_refresh_state,
|
|
848
|
+
statusline_pipeline=statusline_pipeline,
|
|
790
849
|
)
|
|
791
850
|
|
|
792
851
|
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -2650,15 +2650,21 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2650
2650
|
t_export = t_sub.add_parser(
|
|
2651
2651
|
"export", help="Export a whole session as Markdown (anonymized by default)",
|
|
2652
2652
|
formatter_class=CLIHelpFormatter)
|
|
2653
|
-
t_export.add_argument("session_id", metavar="
|
|
2654
|
-
help="Claude sessionId
|
|
2653
|
+
t_export.add_argument("session_id", metavar="ID",
|
|
2654
|
+
help="A Claude sessionId or a v1. conversation key "
|
|
2655
|
+
"(Codex conversations use the v1. key)")
|
|
2655
2656
|
t_export.add_argument(
|
|
2656
2657
|
"--scope", choices=("all", "prompts", "chat", "recipe"), default="all",
|
|
2657
|
-
help="Which slice to export (default: all)")
|
|
2658
|
+
help="Which slice to export (default: all; Codex accepts only 'all')")
|
|
2658
2659
|
t_export.add_argument(
|
|
2659
2660
|
"--raw", action="store_true",
|
|
2660
2661
|
help="Disable the whole scrub (identity + secrets); byte-identical to "
|
|
2661
2662
|
"the dashboard raw export")
|
|
2663
|
+
t_export.add_argument(
|
|
2664
|
+
"--speed", choices=("auto", "standard", "fast"), default=None,
|
|
2665
|
+
help="Codex service tier for per-turn cost (default: auto). Applies only "
|
|
2666
|
+
"to Codex (v1.) conversations; an explicit value on any other ref "
|
|
2667
|
+
"is a usage error")
|
|
2662
2668
|
t_export.add_argument(
|
|
2663
2669
|
"-o", "--output", metavar="PATH", default=None,
|
|
2664
2670
|
help="Write to PATH instead of stdout (same exact bytes)")
|
|
@@ -2668,6 +2674,9 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2668
2674
|
"search", help="Search transcripts across sessions (raw output)",
|
|
2669
2675
|
formatter_class=CLIHelpFormatter)
|
|
2670
2676
|
t_search.add_argument("query", metavar="QUERY", help="Search text")
|
|
2677
|
+
t_search.add_argument(
|
|
2678
|
+
"--source", choices=("claude", "codex"), default="claude",
|
|
2679
|
+
help="Which provider's conversations to search (default: claude)")
|
|
2671
2680
|
t_search.add_argument(
|
|
2672
2681
|
"--kind",
|
|
2673
2682
|
choices=("all", "prompts", "assistant", "tools", "thinking",
|
|
@@ -2676,7 +2685,11 @@ def _build_transcript_parser(subparsers, name, *, help_text, xref=None):
|
|
|
2676
2685
|
t_search.add_argument("--limit", type=int, default=50,
|
|
2677
2686
|
help="Max results (default: 50)")
|
|
2678
2687
|
t_search.add_argument("--offset", type=int, default=0,
|
|
2679
|
-
help="Result offset for pagination (default: 0
|
|
2688
|
+
help="Result offset for pagination (default: 0; "
|
|
2689
|
+
"Claude only — Codex paginates with --cursor)")
|
|
2690
|
+
t_search.add_argument("--cursor", default=None, metavar="TOKEN",
|
|
2691
|
+
help="Codex pagination cursor from a prior nextCursor "
|
|
2692
|
+
"(requires --source codex)")
|
|
2680
2693
|
t_search.add_argument("--project", action="append", default=None,
|
|
2681
2694
|
metavar="LABEL",
|
|
2682
2695
|
help="Filter by project label (repeatable)")
|
package/bin/_cctally_record.py
CHANGED
|
@@ -2548,6 +2548,73 @@ def _credit_json(plan, *, applied, dry_run, forced, stale_replays, hwm_before):
|
|
|
2548
2548
|
}
|
|
2549
2549
|
|
|
2550
2550
|
|
|
2551
|
+
def _revalidate_credit_plan(conn, args, *, now, at_dt, expected_plan):
|
|
2552
|
+
"""Recompute the confirmed credit plan from locked, current DB truth.
|
|
2553
|
+
|
|
2554
|
+
The caller has already completed every preview/refusal/confirmation path.
|
|
2555
|
+
Returning ``None`` is deliberately side-effect free: it means a concurrent
|
|
2556
|
+
writer changed the requested credit's basis and the user must retry rather
|
|
2557
|
+
than authorizing a different mutation than the preview showed.
|
|
2558
|
+
"""
|
|
2559
|
+
try:
|
|
2560
|
+
if getattr(args, "week", None):
|
|
2561
|
+
week_start_date = args.week
|
|
2562
|
+
ws_at, we_at = _get_canonical_boundary_for_date(conn, week_start_date)
|
|
2563
|
+
if not ws_at or not we_at:
|
|
2564
|
+
return None
|
|
2565
|
+
else:
|
|
2566
|
+
fetched = _fetch_current_week_snapshots(conn, at_dt)
|
|
2567
|
+
if fetched is None:
|
|
2568
|
+
return None
|
|
2569
|
+
ws_at, we_at, _samples = fetched
|
|
2570
|
+
ws_at = ws_at if isinstance(ws_at, str) else ws_at.isoformat(timespec="seconds")
|
|
2571
|
+
we_at = we_at if isinstance(we_at, str) else we_at.isoformat(timespec="seconds")
|
|
2572
|
+
week_start_date = parse_iso_datetime(ws_at, "ws_at").date().isoformat()
|
|
2573
|
+
existing = conn.execute(
|
|
2574
|
+
"SELECT id, effective_at_utc, observed_pre_credit_pct "
|
|
2575
|
+
"FROM weekly_credit_floors WHERE week_start_date=? "
|
|
2576
|
+
"ORDER BY unixepoch(effective_at_utc) DESC, id DESC LIMIT 1",
|
|
2577
|
+
(week_start_date,),
|
|
2578
|
+
).fetchone()
|
|
2579
|
+
is_force = bool(getattr(args, "force", False))
|
|
2580
|
+
if getattr(args, "from_pct", None) is not None:
|
|
2581
|
+
from_pct, from_source = float(args.from_pct), "explicit"
|
|
2582
|
+
elif existing is not None and existing[2] is not None:
|
|
2583
|
+
from_pct, from_source = float(existing[2]), "prior_credit"
|
|
2584
|
+
else:
|
|
2585
|
+
from_pct = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at)
|
|
2586
|
+
if from_pct is None:
|
|
2587
|
+
return None
|
|
2588
|
+
from_source = "hwm"
|
|
2589
|
+
is_completion = False
|
|
2590
|
+
if existing is not None and not is_force:
|
|
2591
|
+
owned = conn.execute(
|
|
2592
|
+
"SELECT 1 FROM weekly_usage_snapshots "
|
|
2593
|
+
" WHERE week_start_date=? AND source='record-credit' "
|
|
2594
|
+
" AND unixepoch(captured_at_utc) >= unixepoch(?) LIMIT 1",
|
|
2595
|
+
(week_start_date, existing[1]),
|
|
2596
|
+
).fetchone()
|
|
2597
|
+
is_completion = owned is None
|
|
2598
|
+
if existing is not None and not is_force and not is_completion:
|
|
2599
|
+
return None
|
|
2600
|
+
plan = _build_credit_plan(
|
|
2601
|
+
week_start_date=week_start_date,
|
|
2602
|
+
week_start_at=ws_at,
|
|
2603
|
+
week_end_at=we_at,
|
|
2604
|
+
from_pct=from_pct,
|
|
2605
|
+
from_source=from_source,
|
|
2606
|
+
to_pct=args.to,
|
|
2607
|
+
at_dt=at_dt,
|
|
2608
|
+
now=now,
|
|
2609
|
+
effective_override=existing[1] if is_completion else None,
|
|
2610
|
+
)
|
|
2611
|
+
except (sqlite3.DatabaseError, ValueError, TypeError):
|
|
2612
|
+
return None
|
|
2613
|
+
if plan != expected_plan:
|
|
2614
|
+
return None
|
|
2615
|
+
return plan, existing, is_completion
|
|
2616
|
+
|
|
2617
|
+
|
|
2551
2618
|
def cmd_record_credit(args) -> int:
|
|
2552
2619
|
now = _command_as_of()
|
|
2553
2620
|
try:
|
|
@@ -2704,23 +2771,68 @@ def cmd_record_credit(args) -> int:
|
|
|
2704
2771
|
print("aborted — nothing written")
|
|
2705
2772
|
return 0
|
|
2706
2773
|
|
|
2707
|
-
#
|
|
2708
|
-
#
|
|
2709
|
-
#
|
|
2710
|
-
#
|
|
2711
|
-
#
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2774
|
+
# Every non-mutating exit is above this point. Re-open under the
|
|
2775
|
+
# selected-state writer lock, recompute from current DB truth, and
|
|
2776
|
+
# refuse plan drift before any durable pipeline artifact is visible.
|
|
2777
|
+
# This is intentionally after confirmation: preview/no/TTY refusal
|
|
2778
|
+
# must not create an inflight tombstone merely by inspecting a credit.
|
|
2779
|
+
conn.close()
|
|
2780
|
+
conn = None
|
|
2781
|
+
c = _cctally()
|
|
2782
|
+
with c._selected_state_lock():
|
|
2783
|
+
conn = open_db()
|
|
2784
|
+
revalidated = c._revalidate_credit_plan(
|
|
2785
|
+
conn,
|
|
2786
|
+
args,
|
|
2787
|
+
now=now,
|
|
2788
|
+
at_dt=at_dt,
|
|
2789
|
+
expected_plan=plan,
|
|
2790
|
+
)
|
|
2791
|
+
if revalidated is None:
|
|
2792
|
+
eprint("record-credit: plan changed while awaiting confirmation; retry")
|
|
2793
|
+
return 2
|
|
2794
|
+
plan, existing, is_completion = revalidated
|
|
2795
|
+
stale_replays = _count_stale_replays(conn, plan)
|
|
2796
|
+
hwm_before = _resolve_reset_aware_hwm(
|
|
2797
|
+
conn, plan.week_start_date, plan.week_start_at, plan.week_end_at
|
|
2798
|
+
)
|
|
2799
|
+
if hwm_before is None:
|
|
2800
|
+
hwm_before = plan.from_pct
|
|
2801
|
+
try:
|
|
2802
|
+
handles = c._authoritative_begin(
|
|
2803
|
+
{"sevenDay"}, now_epoch=int(time.time())
|
|
2804
|
+
)
|
|
2805
|
+
except (OSError, ValueError) as exc:
|
|
2806
|
+
eprint(f"record-credit: could not prepare authoritative state: {exc}")
|
|
2807
|
+
return 3
|
|
2808
|
+
|
|
2809
|
+
# Existing-floor handling remains exactly scoped to this week, but
|
|
2810
|
+
# the weekly tombstone is already fail-closed immediately before
|
|
2811
|
+
# either mutation kernel runs.
|
|
2812
|
+
forced = False
|
|
2813
|
+
if existing is not None and is_force:
|
|
2814
|
+
_force_clear_credit(conn, plan.week_start_date)
|
|
2815
|
+
forced = True
|
|
2816
|
+
five_hour = _resolve_prior_5h(conn, at_dt)
|
|
2817
|
+
_apply_credit(conn, plan, five_hour=five_hour)
|
|
2818
|
+
|
|
2819
|
+
# A successful credit is authoritative only after the post-credit
|
|
2820
|
+
# DB state has a stable projection and all selected artifacts are
|
|
2821
|
+
# atomically committed. Leave inflight on any failure so a later
|
|
2822
|
+
# authority repair remains fail-closed.
|
|
2823
|
+
try:
|
|
2824
|
+
projection = c._read_db_projection_stable()
|
|
2825
|
+
completion_epoch = int(time.time())
|
|
2826
|
+
c._authoritative_commit(
|
|
2827
|
+
handles, completion_epoch=completion_epoch
|
|
2828
|
+
)
|
|
2829
|
+
c._reconcile_selected_control(
|
|
2830
|
+
projection, now_epoch=completion_epoch, observed_axes={"sevenDay"}
|
|
2831
|
+
)
|
|
2832
|
+
c._statusline_observe_touch()
|
|
2833
|
+
except Exception as exc:
|
|
2834
|
+
eprint(f"record-credit: authoritative state incomplete: {exc}")
|
|
2835
|
+
return 3
|
|
2724
2836
|
|
|
2725
2837
|
if is_json:
|
|
2726
2838
|
print(json.dumps(_credit_json(
|
|
@@ -3757,13 +3869,14 @@ def _hook_tick_throttle_touch() -> None:
|
|
|
3757
3869
|
|
|
3758
3870
|
|
|
3759
3871
|
# =========================================================================
|
|
3760
|
-
# Statusline
|
|
3872
|
+
# Statusline selected/transport markers + OAuth backoff deadline (#318)
|
|
3761
3873
|
# =========================================================================
|
|
3762
3874
|
#
|
|
3763
3875
|
# Two independent markers with two DIFFERENT time encodings:
|
|
3764
|
-
# - The observation marker is MTIME-based
|
|
3765
|
-
#
|
|
3766
|
-
#
|
|
3876
|
+
# - The selected-observation marker is MTIME-based and represents an actual
|
|
3877
|
+
# selected DB change or authoritative OAuth confirmation.
|
|
3878
|
+
# - The transport marker is also MTIME-based and represents an eligible
|
|
3879
|
+
# regular-pool candidate reaching the spool; it never throttles OAuth.
|
|
3767
3880
|
# - The OAuth backoff marker is CONTENT-based: it stores a FUTURE absolute
|
|
3768
3881
|
# epoch deadline as text. Its mtime is meaningless (~now). Encoding the
|
|
3769
3882
|
# deadline as mtime would future-date the file and, if ever confused
|
|
@@ -3771,11 +3884,9 @@ def _hook_tick_throttle_touch() -> None:
|
|
|
3771
3884
|
# deliberate split (Codex P1-3).
|
|
3772
3885
|
|
|
3773
3886
|
|
|
3774
|
-
def
|
|
3775
|
-
"""Seconds since the statusline last successfully fed usage; +inf if
|
|
3776
|
-
never. Mtime-based, mirroring ``_hook_tick_throttle_age_seconds``."""
|
|
3887
|
+
def _marker_age(path) -> float:
|
|
3777
3888
|
try:
|
|
3778
|
-
mtime =
|
|
3889
|
+
mtime = path.stat().st_mtime
|
|
3779
3890
|
except FileNotFoundError:
|
|
3780
3891
|
return float("inf")
|
|
3781
3892
|
except OSError:
|
|
@@ -3783,20 +3894,38 @@ def _statusline_observe_age_seconds() -> float:
|
|
|
3783
3894
|
return max(0.0, time.time() - mtime)
|
|
3784
3895
|
|
|
3785
3896
|
|
|
3786
|
-
def
|
|
3787
|
-
"""Mark the statusline as alive (mtime := now, creating if missing).
|
|
3788
|
-
|
|
3789
|
-
Called after every persist attempt that fed valid 7d input through
|
|
3790
|
-
``cmd_record_usage`` successfully — INCLUDING a dedup (no-insert)
|
|
3791
|
-
return, so the throttles reflect liveness rather than snapshot age."""
|
|
3897
|
+
def _touch_marker(path) -> None:
|
|
3792
3898
|
try:
|
|
3793
3899
|
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
3794
|
-
|
|
3795
|
-
os.utime(
|
|
3900
|
+
path.touch(exist_ok=True)
|
|
3901
|
+
os.utime(path, None)
|
|
3796
3902
|
except OSError:
|
|
3797
3903
|
pass
|
|
3798
3904
|
|
|
3799
3905
|
|
|
3906
|
+
def _statusline_observe_age_seconds() -> float:
|
|
3907
|
+
"""Seconds since selected/authoritative usage changed; +inf if never."""
|
|
3908
|
+
return _marker_age(_cctally_core.STATUSLINE_OBSERVE_MARKER_PATH)
|
|
3909
|
+
|
|
3910
|
+
|
|
3911
|
+
def _statusline_observe_touch() -> None:
|
|
3912
|
+
"""Mark selected usage freshness after a proven selected transition.
|
|
3913
|
+
|
|
3914
|
+
The historical public helper names remain aliases for selected freshness.
|
|
3915
|
+
"""
|
|
3916
|
+
_touch_marker(_cctally_core.STATUSLINE_OBSERVE_MARKER_PATH)
|
|
3917
|
+
|
|
3918
|
+
|
|
3919
|
+
def _statusline_transport_age_seconds() -> float:
|
|
3920
|
+
"""Seconds since an eligible regular-pool candidate reached the spool."""
|
|
3921
|
+
return _marker_age(_cctally_core.STATUSLINE_TRANSPORT_MARKER_PATH)
|
|
3922
|
+
|
|
3923
|
+
|
|
3924
|
+
def _statusline_transport_touch() -> None:
|
|
3925
|
+
"""Mark regular-pool statusline transport after an atomic candidate write."""
|
|
3926
|
+
_touch_marker(_cctally_core.STATUSLINE_TRANSPORT_MARKER_PATH)
|
|
3927
|
+
|
|
3928
|
+
|
|
3800
3929
|
def _oauth_backoff_remaining_seconds() -> float:
|
|
3801
3930
|
"""Seconds until the shared OAuth 429 backoff deadline; ``0.0`` when the
|
|
3802
3931
|
marker is absent, empty, malformed, or already elapsed.
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -624,10 +624,6 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
624
624
|
status="fetch_failed",
|
|
625
625
|
reason=f"invalid oauth_usage config: {exc}",
|
|
626
626
|
)
|
|
627
|
-
# Successful API response — clear any pending 429 backoff + counter, so a
|
|
628
|
-
# user-confirmed recovery lets automatic polling resume (spec §4).
|
|
629
|
-
c._oauth_backoff_reset()
|
|
630
|
-
|
|
631
627
|
seven = api.get("seven_day") or {}
|
|
632
628
|
try:
|
|
633
629
|
# Normalize at the OAuth ingress so the payload JSON published
|
|
@@ -681,12 +677,21 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
681
677
|
# this, cmd_record_usage would fall back to its "statusline" default.
|
|
682
678
|
source="api",
|
|
683
679
|
)
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
680
|
+
authoritative = c._authoritative_record_usage(
|
|
681
|
+
record_args,
|
|
682
|
+
{
|
|
683
|
+
"sevenDay",
|
|
684
|
+
*({"fiveHour"} if five_pct is not None else set()),
|
|
685
|
+
},
|
|
686
|
+
)
|
|
687
|
+
if authoritative.status != "ok":
|
|
688
|
+
return _RefreshUsageResult(
|
|
689
|
+
status="record_failed", reason=authoritative.reason
|
|
690
|
+
)
|
|
691
|
+
# A fetched response becomes evidence of recovery only after its
|
|
692
|
+
# write-ahead tombstones, database reconciliation, selected control, and
|
|
693
|
+
# selected-freshness marker have all committed.
|
|
694
|
+
c._oauth_backoff_reset()
|
|
690
695
|
|
|
691
696
|
# §5.6 Option C: route through cctally's namespace so
|
|
692
697
|
# `monkeypatch.setitem(ns, "_bust_statusline_cache", …)` propagates
|
|
@@ -877,18 +882,38 @@ def _hook_tick_oauth_refresh(
|
|
|
877
882
|
throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
|
|
878
883
|
except OauthUsageConfigError:
|
|
879
884
|
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
880
|
-
#
|
|
881
|
-
#
|
|
882
|
-
#
|
|
883
|
-
#
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
885
|
+
# The automatic writer takes the selected-state lock *before* rechecking
|
|
886
|
+
# its suppression conditions. A concurrent publisher can otherwise make
|
|
887
|
+
# this tick issue an unnecessary OAuth request between the initial gate
|
|
888
|
+
# and the request itself.
|
|
889
|
+
try:
|
|
890
|
+
with c._selected_state_lock():
|
|
891
|
+
return _hook_tick_oauth_refresh_locked(
|
|
892
|
+
c,
|
|
893
|
+
token=token,
|
|
894
|
+
timeout_seconds=timeout_seconds,
|
|
895
|
+
throttle_seconds=throttle_seconds,
|
|
896
|
+
)
|
|
897
|
+
except OSError:
|
|
898
|
+
return "err(record-usage=exc)", None
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _hook_tick_oauth_refresh_locked(
|
|
902
|
+
c,
|
|
903
|
+
*,
|
|
904
|
+
token: str,
|
|
905
|
+
timeout_seconds: float,
|
|
906
|
+
throttle_seconds: float,
|
|
907
|
+
) -> tuple[str, dict | None]:
|
|
908
|
+
"""Automatic OAuth path with the selected-state lock already held."""
|
|
909
|
+
# Backfill gate: an inflight/invalid tombstone bypasses only selected-age
|
|
910
|
+
# suppression so a later authoritative result can repair it. The normal
|
|
911
|
+
# throttle and 429 deadline continue to bound OAuth traffic.
|
|
912
|
+
now_epoch = int(time.time())
|
|
913
|
+
needs_repair = c._authoritative_repair_required(now_epoch=now_epoch)
|
|
890
914
|
obs_age = c._statusline_observe_age_seconds()
|
|
891
|
-
if
|
|
915
|
+
if (not needs_repair
|
|
916
|
+
and obs_age < float(_cctally_core.OAUTH_BACKFILL_STALE_SECONDS)):
|
|
892
917
|
return f"skipped(statusline-fresh:{int(obs_age)}s)", None
|
|
893
918
|
backoff_remaining = c._oauth_backoff_remaining_seconds()
|
|
894
919
|
if backoff_remaining > 0:
|
|
@@ -899,9 +924,6 @@ def _hook_tick_oauth_refresh(
|
|
|
899
924
|
try:
|
|
900
925
|
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
901
926
|
except RefreshUsageRateLimitError as exc:
|
|
902
|
-
# Arm the shared backoff deadline so the next tick is suppressed
|
|
903
|
-
# rather than immediately re-polling (spec §4). Honors Retry-After
|
|
904
|
-
# when present, else conservative exponential backoff.
|
|
905
927
|
c._oauth_backoff_register_429(
|
|
906
928
|
retry_after_deadline=getattr(exc, "retry_after_deadline", None),
|
|
907
929
|
now=time.time(),
|
|
@@ -911,8 +933,6 @@ def _hook_tick_oauth_refresh(
|
|
|
911
933
|
return "err(network)", None
|
|
912
934
|
except RefreshUsageMalformedError:
|
|
913
935
|
return "err(parse)", None
|
|
914
|
-
# Successful API response — clear any pending 429 backoff + counter.
|
|
915
|
-
c._oauth_backoff_reset()
|
|
916
936
|
seven = api["seven_day"]
|
|
917
937
|
try:
|
|
918
938
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
@@ -922,10 +942,6 @@ def _hook_tick_oauth_refresh(
|
|
|
922
942
|
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
923
943
|
five_pct: float | None = None
|
|
924
944
|
five_resets_epoch: int | None = None
|
|
925
|
-
# An inactive 5h window arrives as `resets_at: null` (key present, value
|
|
926
|
-
# null). Require a string here — mirrors the seven_day guard in
|
|
927
|
-
# _fetch_oauth_usage — so _iso_to_epoch(None) can't raise AttributeError;
|
|
928
|
-
# a malformed (non-null) string still degrades via the except below.
|
|
929
945
|
if (five is not None and "utilization" in five
|
|
930
946
|
and isinstance(five.get("resets_at"), str)):
|
|
931
947
|
try:
|
|
@@ -939,15 +955,21 @@ def _hook_tick_oauth_refresh(
|
|
|
939
955
|
resets_at=str(seven_resets_epoch),
|
|
940
956
|
five_hour_percent=five_pct,
|
|
941
957
|
five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
|
|
942
|
-
# OAuth feeder — label the row "api" (spec §5).
|
|
943
958
|
source="api",
|
|
944
959
|
)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
960
|
+
authoritative = c._authoritative_record_usage(
|
|
961
|
+
record_args,
|
|
962
|
+
{"sevenDay", *({"fiveHour"} if five_pct is not None else set())},
|
|
963
|
+
lock_held=True,
|
|
964
|
+
)
|
|
965
|
+
if authoritative.status != "ok":
|
|
966
|
+
reason = authoritative.reason or ""
|
|
967
|
+
if reason.startswith("exit "):
|
|
968
|
+
return f"err(record-usage={reason[5:]})", None
|
|
948
969
|
return "err(record-usage=exc)", None
|
|
949
|
-
|
|
950
|
-
|
|
970
|
+
# Success is acknowledged to the shared OAuth backoff only after the
|
|
971
|
+
# authoritative writer has published tombstones, control, and freshness.
|
|
972
|
+
c._oauth_backoff_reset()
|
|
951
973
|
parts = [f"7d={int(round(seven_pct))}"]
|
|
952
974
|
if five_pct is not None:
|
|
953
975
|
parts.append(f"5h={int(round(five_pct))}")
|