cctally 1.72.0 → 1.74.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 +22 -0
- package/bin/_cctally_cache.py +271 -55
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard.py +35 -1
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_dashboard_share.py +38 -3
- package/bin/_cctally_dashboard_sources.py +812 -74
- package/bin/_cctally_db.py +104 -1
- package/bin/_cctally_doctor.py +86 -4
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_source_analytics.py +288 -3
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_cctally_tui.py +17 -14
- package/bin/_lib_aggregators.py +48 -34
- 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_dashboard_sources.py +20 -7
- package/bin/_lib_doctor.py +106 -0
- package/bin/_lib_jsonl.py +11 -0
- package/bin/_lib_pricing.py +7 -4
- package/bin/_lib_pricing_check.py +7 -1
- package/bin/_lib_quota.py +12 -11
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/_lib_view_models.py +50 -14
- package/bin/cctally +31 -2
- package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
- package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BWadAHxC.js +0 -80
- package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -876,6 +876,10 @@ def _run_pending_migrations(
|
|
|
876
876
|
# absent must still be treated as non-fresh so a flag-only
|
|
877
877
|
# conversation migration's consumer actually runs (e.g. 011's
|
|
878
878
|
# command-args promotion) instead of being stamped without it.
|
|
879
|
+
# codex_session_entries joins them (#312): an accounting-bearing
|
|
880
|
+
# cache written before conversation-key enrichment but missing
|
|
881
|
+
# schema_migrations must not be stamped fresh, or 026 would never
|
|
882
|
+
# clear it for the required byte-zero Codex replay.
|
|
879
883
|
# codex_conversation_events joins them (#294 S6): a Codex-bearing
|
|
880
884
|
# cache written by S1's fused ingest but missing schema_migrations
|
|
881
885
|
# would otherwise be classified fresh and stamp 025 WITHOUT replaying
|
|
@@ -883,7 +887,7 @@ def _run_pending_migrations(
|
|
|
883
887
|
# normalized corpus. Probing it forces 025's handler to run and derive
|
|
884
888
|
# the normalized rows from the retained events.
|
|
885
889
|
"cache.db": (
|
|
886
|
-
"session_entries", "conversation_messages",
|
|
890
|
+
"session_entries", "conversation_messages", "codex_session_entries",
|
|
887
891
|
"codex_conversation_events",
|
|
888
892
|
),
|
|
889
893
|
}.get(db_label, ())
|
|
@@ -4583,6 +4587,105 @@ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
|
|
|
4583
4587
|
lock_fh.close()
|
|
4584
4588
|
|
|
4585
4589
|
|
|
4590
|
+
@cache_migration("026_codex_conversation_key_backfill")
|
|
4591
|
+
def _026_codex_conversation_key_backfill(conn: sqlite3.Connection) -> None:
|
|
4592
|
+
"""Arm a byte-zero Codex replay for missing conversation-key metadata (#312).
|
|
4593
|
+
|
|
4594
|
+
Historical accounting rows predate the source-derived conversation-key
|
|
4595
|
+
enrichment. The cache cannot truthfully recover those keys on its own, so
|
|
4596
|
+
clear exactly the existing Codex-derived families and let the next Codex
|
|
4597
|
+
sync replay retained rollout data from byte zero. This shares the runtime
|
|
4598
|
+
clear helper with ``sync_codex_cache(rebuild=True)`` so new or future Codex
|
|
4599
|
+
families cannot drift from the migration's destructive scope.
|
|
4600
|
+
|
|
4601
|
+
The Codex flock is acquired before ``BEGIN IMMEDIATE``. Contention defers
|
|
4602
|
+
through ``MigrationGateNotMet`` before any DML; after a handler/data commit
|
|
4603
|
+
but before the dispatcher's central stamp, a rerun against the empty state
|
|
4604
|
+
is a byte-idempotent no-op. The physical mutation sequence advances only
|
|
4605
|
+
when the shared clear actually changed persisted Codex state. The handler
|
|
4606
|
+
never self-stamps.
|
|
4607
|
+
"""
|
|
4608
|
+
lock_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4609
|
+
lock_fh = None
|
|
4610
|
+
if lock_path is not None:
|
|
4611
|
+
lock_fh = open(lock_path, "w")
|
|
4612
|
+
try:
|
|
4613
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4614
|
+
except BlockingIOError:
|
|
4615
|
+
lock_fh.close()
|
|
4616
|
+
raise MigrationGateNotMet(
|
|
4617
|
+
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4618
|
+
"deferring cache 026 conversation-key backfill (#312)"
|
|
4619
|
+
)
|
|
4620
|
+
try:
|
|
4621
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4622
|
+
try:
|
|
4623
|
+
import _cctally_cache
|
|
4624
|
+
|
|
4625
|
+
if _cctally_cache._clear_codex_derived_rows(conn):
|
|
4626
|
+
_cctally_cache._bump_codex_physical_mutation_seq(conn)
|
|
4627
|
+
conn.commit()
|
|
4628
|
+
except Exception:
|
|
4629
|
+
conn.rollback()
|
|
4630
|
+
raise
|
|
4631
|
+
finally:
|
|
4632
|
+
if lock_fh is not None:
|
|
4633
|
+
try:
|
|
4634
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4635
|
+
except OSError:
|
|
4636
|
+
pass
|
|
4637
|
+
lock_fh.close()
|
|
4638
|
+
|
|
4639
|
+
|
|
4640
|
+
@cache_migration("027_codex_fork_preamble_rebuild")
|
|
4641
|
+
def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
|
|
4642
|
+
"""Replay Codex rollouts after suppressing copied fork-preamble accounting.
|
|
4643
|
+
|
|
4644
|
+
Forked/subagent JSONL can retain parent ``token_count`` records before the
|
|
4645
|
+
child's first model-bearing ``turn_context``. Older ingest projected those
|
|
4646
|
+
copied records into ``codex_session_entries`` as ``model='unknown'``, double
|
|
4647
|
+
counting usage already present in the parent rollout. The source records
|
|
4648
|
+
remain authoritative, so clear all re-derivable Codex families and let the
|
|
4649
|
+
corrected fused parser replay them from byte zero.
|
|
4650
|
+
|
|
4651
|
+
The Codex flock precedes ``BEGIN IMMEDIATE``. Contention defers before DML;
|
|
4652
|
+
a markerless retry against already-cleared state is a no-op. The shared
|
|
4653
|
+
clear helper keeps this migration aligned with runtime rebuild scope and
|
|
4654
|
+
advances the physical mutation sequence only when state actually changed.
|
|
4655
|
+
The dispatcher owns the migration marker.
|
|
4656
|
+
"""
|
|
4657
|
+
lock_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4658
|
+
lock_fh = None
|
|
4659
|
+
if lock_path is not None:
|
|
4660
|
+
lock_fh = open(lock_path, "w")
|
|
4661
|
+
try:
|
|
4662
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4663
|
+
except BlockingIOError:
|
|
4664
|
+
lock_fh.close()
|
|
4665
|
+
raise MigrationGateNotMet(
|
|
4666
|
+
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4667
|
+
"deferring cache 027 fork-preamble rebuild"
|
|
4668
|
+
)
|
|
4669
|
+
try:
|
|
4670
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4671
|
+
try:
|
|
4672
|
+
import _cctally_cache
|
|
4673
|
+
|
|
4674
|
+
if _cctally_cache._clear_codex_derived_rows(conn):
|
|
4675
|
+
_cctally_cache._bump_codex_physical_mutation_seq(conn)
|
|
4676
|
+
conn.commit()
|
|
4677
|
+
except Exception:
|
|
4678
|
+
conn.rollback()
|
|
4679
|
+
raise
|
|
4680
|
+
finally:
|
|
4681
|
+
if lock_fh is not None:
|
|
4682
|
+
try:
|
|
4683
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4684
|
+
except OSError:
|
|
4685
|
+
pass
|
|
4686
|
+
lock_fh.close()
|
|
4687
|
+
|
|
4688
|
+
|
|
4586
4689
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
4587
4690
|
|
|
4588
4691
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
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
|
|
@@ -432,6 +490,8 @@ def doctor_gather_state(
|
|
|
432
490
|
|
|
433
491
|
codex_entries_count = None
|
|
434
492
|
codex_last_entry_at = None
|
|
493
|
+
codex_project_metadata_health = None
|
|
494
|
+
codex_project_metadata_error = None
|
|
435
495
|
try:
|
|
436
496
|
if _cctally_core.CACHE_DB_PATH.exists():
|
|
437
497
|
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
@@ -445,12 +505,31 @@ def doctor_gather_state(
|
|
|
445
505
|
codex_last_entry_at = parse_iso_datetime(
|
|
446
506
|
row[1], "codex_session_entries.timestamp_utc",
|
|
447
507
|
).astimezone(dt.timezone.utc)
|
|
448
|
-
|
|
449
|
-
|
|
508
|
+
# Keep the health probe on the existing read-only cache
|
|
509
|
+
# connection. A failed probe is health evidence, not an
|
|
510
|
+
# empty corpus: the kernel renders it as a distinct FAIL.
|
|
511
|
+
try:
|
|
512
|
+
import _cctally_source_analytics
|
|
513
|
+
|
|
514
|
+
health = _cctally_source_analytics.load_codex_project_metadata_health(
|
|
515
|
+
cache_conn=conn,
|
|
516
|
+
)
|
|
517
|
+
codex_project_metadata_health = {
|
|
518
|
+
"total_rows": health.total_rows,
|
|
519
|
+
"qualified_rows": health.qualified_rows,
|
|
520
|
+
"missing_conversation_key_rows": health.missing_conversation_key_rows,
|
|
521
|
+
"missing_thread_join_rows": health.missing_thread_join_rows,
|
|
522
|
+
}
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
codex_project_metadata_error = type(exc).__name__
|
|
525
|
+
except sqlite3.OperationalError as exc:
|
|
526
|
+
# Pre-Codex cache shapes still produce the established Codex
|
|
527
|
+
# cache result, while the new health check fails explicitly.
|
|
528
|
+
codex_project_metadata_error = type(exc).__name__
|
|
450
529
|
finally:
|
|
451
530
|
conn.close()
|
|
452
|
-
except Exception:
|
|
453
|
-
|
|
531
|
+
except Exception as exc:
|
|
532
|
+
codex_project_metadata_error = type(exc).__name__
|
|
454
533
|
|
|
455
534
|
# Issue #109: probe every $CODEX_HOME session root (not the single
|
|
456
535
|
# hardcoded ~/.codex/sessions), matching the multi-root ingestion path
|
|
@@ -744,6 +823,8 @@ def doctor_gather_state(
|
|
|
744
823
|
codex_entries_count=codex_entries_count,
|
|
745
824
|
codex_last_entry_at=codex_last_entry_at,
|
|
746
825
|
codex_jsonl_present=codex_jsonl_present,
|
|
826
|
+
codex_project_metadata_health=codex_project_metadata_health,
|
|
827
|
+
codex_project_metadata_error=codex_project_metadata_error,
|
|
747
828
|
dashboard_bind_stored=dashboard_bind_stored,
|
|
748
829
|
runtime_bind=runtime_bind,
|
|
749
830
|
# Conversation viewer (Plan 2, spec §5): only consulted on a LAN bind.
|
|
@@ -787,6 +868,7 @@ def doctor_gather_state(
|
|
|
787
868
|
codex_lifecycle_activity_24h=codex_lifecycle_activity_24h,
|
|
788
869
|
# #311: precomputed statusLine.refreshInterval classification.
|
|
789
870
|
statusline_refresh_state=statusline_refresh_state,
|
|
871
|
+
statusline_pipeline=statusline_pipeline,
|
|
790
872
|
)
|
|
791
873
|
|
|
792
874
|
|
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.
|