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.
Files changed (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
@@ -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")
@@ -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
- except sqlite3.OperationalError:
449
- pass
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
- pass
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
 
@@ -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="SESSION_ID",
2654
- help="Claude sessionId to export")
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)")
@@ -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
- # 4a. Existing-floor handling (M2; state classified at step 2a). The
2708
- # fully-applied refuse was hoisted above the confirm prompt (#212
2709
- # N2), so only two cases remain here: `--force` clears the prior
2710
- # floor + re-records at a fresh effective; a half-applied credit
2711
- # (floor row, no command-owned snapshot a crash between
2712
- # _apply_credit's floor commit and the synthetic snapshot) falls
2713
- # through to a completion (the plan reuses the existing effective
2714
- # and the apply steps are idempotent). `existing`/`is_completion`
2715
- # were resolved up front keyed on week_start_date /
2716
- # weekly_credit_floors.
2717
- forced = False
2718
- if existing is not None and is_force:
2719
- _force_clear_credit(conn, plan.week_start_date)
2720
- forced = True
2721
-
2722
- five_hour = _resolve_prior_5h(conn, at_dt)
2723
- _apply_credit(conn, plan, five_hour=five_hour)
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 observation marker + OAuth backoff deadline (spec 2026-07-17)
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 (mirrors _hook_tick_throttle_*):
3765
- # it answers "how long since the statusline last fed?", so its age is
3766
- # the signal. It is touched even on a dedup no-op.
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 _statusline_observe_age_seconds() -> float:
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 = _cctally_core.STATUSLINE_OBSERVE_MARKER_PATH.stat().st_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 _statusline_observe_touch() -> None:
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
- _cctally_core.STATUSLINE_OBSERVE_MARKER_PATH.touch(exist_ok=True)
3795
- os.utime(_cctally_core.STATUSLINE_OBSERVE_MARKER_PATH, None)
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.