cctally 1.71.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 +20 -0
- package/bin/_cctally_cache.py +501 -49
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_db.py +286 -1
- 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.py +509 -0
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +1422 -0
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +809 -0
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +8 -1
|
@@ -27,16 +27,24 @@ Spec: docs/superpowers/specs/2026-05-30-extract-five-hour-statusline-cmd-design.
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
29
|
import argparse
|
|
30
|
+
import dataclasses
|
|
30
31
|
import datetime as dt
|
|
31
32
|
import fcntl
|
|
33
|
+
import hashlib
|
|
32
34
|
import json
|
|
35
|
+
import math
|
|
33
36
|
import os
|
|
34
37
|
import pathlib
|
|
38
|
+
import re
|
|
39
|
+
import secrets
|
|
40
|
+
import sqlite3
|
|
35
41
|
import sys
|
|
42
|
+
import time
|
|
36
43
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
37
44
|
|
|
38
45
|
import _cctally_core
|
|
39
46
|
import _lib_statusline
|
|
47
|
+
import _lib_statusline_candidates as _candidates
|
|
40
48
|
from _cctally_core import _command_as_of, eprint, open_db
|
|
41
49
|
|
|
42
50
|
|
|
@@ -334,6 +342,56 @@ def _release_persist_lock(fd: "int | None") -> None:
|
|
|
334
342
|
pass
|
|
335
343
|
|
|
336
344
|
|
|
345
|
+
class _AuthoritativeRecordResult:
|
|
346
|
+
"""Outcome of the selected-state authoritative writer protocol.
|
|
347
|
+
|
|
348
|
+
This deliberately carries a small status surface rather than leaking a
|
|
349
|
+
SQLite result. A non-``ok`` result means the durable tombstone remains
|
|
350
|
+
inflight, so callers must not touch selected freshness or clear OAuth
|
|
351
|
+
backoff state.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(self, status: str, reason: str | None = None):
|
|
355
|
+
self.status = status
|
|
356
|
+
self.reason = reason
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class _SelectedStateLock:
|
|
360
|
+
"""Blocking owner of the one selected-state writer critical section."""
|
|
361
|
+
|
|
362
|
+
def __init__(self):
|
|
363
|
+
self._fd = -1
|
|
364
|
+
|
|
365
|
+
def __enter__(self):
|
|
366
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
self._fd = os.open(
|
|
368
|
+
_cctally_core.STATUSLINE_PERSIST_LOCK_PATH,
|
|
369
|
+
os.O_WRONLY | os.O_CREAT,
|
|
370
|
+
0o644,
|
|
371
|
+
)
|
|
372
|
+
fcntl.flock(self._fd, fcntl.LOCK_EX)
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
376
|
+
if self._fd < 0:
|
|
377
|
+
return False
|
|
378
|
+
try:
|
|
379
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
382
|
+
try:
|
|
383
|
+
os.close(self._fd)
|
|
384
|
+
except OSError:
|
|
385
|
+
pass
|
|
386
|
+
self._fd = -1
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _selected_state_lock() -> _SelectedStateLock:
|
|
391
|
+
"""Return the blocking lock shared by every selected-state writer."""
|
|
392
|
+
return _SelectedStateLock()
|
|
393
|
+
|
|
394
|
+
|
|
337
395
|
def _record_args(*, percent, resets_at, five_hour_percent, five_hour_resets_at,
|
|
338
396
|
source):
|
|
339
397
|
"""Build the cmd_record_usage Namespace the feeder passes to the kernel.
|
|
@@ -353,40 +411,752 @@ def _record_args(*, percent, resets_at, five_hour_percent, five_hour_resets_at,
|
|
|
353
411
|
)
|
|
354
412
|
|
|
355
413
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
414
|
+
_CANDIDATE_FINAL_RE = re.compile(r"[0-9a-f]{64}\.json\Z")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class ProjectionUnstable(RuntimeError):
|
|
418
|
+
"""stats.db changed while its selected projection was being read."""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _candidate_identity_token(parsed) -> str:
|
|
422
|
+
if isinstance(parsed.session_id, str) and parsed.session_id:
|
|
423
|
+
kind, value = "session_id", parsed.session_id
|
|
424
|
+
elif isinstance(parsed.transcript_path, str) and parsed.transcript_path:
|
|
425
|
+
kind, value = "transcript_path", parsed.transcript_path
|
|
426
|
+
else:
|
|
427
|
+
kind, value = "anonymous", ""
|
|
428
|
+
return hashlib.sha256(f"{kind}\0{value}".encode("utf-8")).hexdigest()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _statusline_reset_is_plausible(axis: str, epoch: int, now_epoch: int) -> bool:
|
|
432
|
+
if not isinstance(epoch, int) or isinstance(epoch, bool):
|
|
433
|
+
return False
|
|
434
|
+
if axis == "fiveHour":
|
|
435
|
+
return now_epoch - 600 <= epoch <= now_epoch + 6 * 3600
|
|
436
|
+
return now_epoch - 30 * 86400 <= epoch <= now_epoch + 8 * 86400
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _candidate_from_input(parsed, *, received_at: int) -> "_candidates.Candidate | None":
|
|
440
|
+
def axis(percent, resets_at, name):
|
|
441
|
+
if isinstance(percent, bool) or not isinstance(percent, (int, float)):
|
|
442
|
+
return None
|
|
443
|
+
if not math.isfinite(float(percent)) or not 0 <= float(percent) <= 100:
|
|
444
|
+
return None
|
|
445
|
+
if not _statusline_reset_is_plausible(name, resets_at, received_at):
|
|
446
|
+
return None
|
|
447
|
+
return _candidates.AxisValue(float(percent), int(resets_at))
|
|
448
|
+
|
|
449
|
+
five = axis(parsed.rate_limits_5h_pct, parsed.rate_limits_5h_resets_at, "fiveHour")
|
|
450
|
+
seven = axis(parsed.rate_limits_7d_pct, parsed.rate_limits_7d_resets_at, "sevenDay")
|
|
451
|
+
if five is None and seven is None:
|
|
452
|
+
return None
|
|
453
|
+
return _candidates.Candidate(
|
|
454
|
+
token=_candidate_identity_token(parsed),
|
|
455
|
+
received_at=received_at,
|
|
456
|
+
five_hour=five,
|
|
457
|
+
seven_day=seven,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _candidate_path(token: str) -> pathlib.Path:
|
|
462
|
+
return _cctally_core.STATUSLINE_CANDIDATE_DIR / f"{token}.json"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _atomic_write_json(path: pathlib.Path, document: dict) -> None:
|
|
466
|
+
"""Publish compact JSON with a unique same-directory exclusive temp file."""
|
|
467
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
if path.parent == _cctally_core.STATUSLINE_CANDIDATE_DIR:
|
|
469
|
+
os.chmod(path.parent, 0o700)
|
|
470
|
+
token = secrets.token_hex(16)
|
|
471
|
+
temp = path.parent / f".{path.name}.tmp.{os.getpid()}.{token}"
|
|
472
|
+
fd = -1
|
|
473
|
+
try:
|
|
474
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
475
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
476
|
+
fd = -1
|
|
477
|
+
json.dump(document, handle, allow_nan=False, separators=(",", ":"))
|
|
478
|
+
os.replace(temp, path)
|
|
479
|
+
try:
|
|
480
|
+
os.chmod(path, 0o600)
|
|
481
|
+
except OSError:
|
|
482
|
+
pass
|
|
483
|
+
finally:
|
|
484
|
+
if fd >= 0:
|
|
485
|
+
try:
|
|
486
|
+
os.close(fd)
|
|
487
|
+
except OSError:
|
|
488
|
+
pass
|
|
489
|
+
try:
|
|
490
|
+
temp.unlink()
|
|
491
|
+
except FileNotFoundError:
|
|
492
|
+
pass
|
|
493
|
+
except OSError:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _candidate_document(candidate: "_candidates.Candidate") -> dict:
|
|
498
|
+
document = {"schemaVersion": 1, "receivedAt": candidate.received_at}
|
|
499
|
+
if candidate.five_hour is not None:
|
|
500
|
+
document["fiveHour"] = {
|
|
501
|
+
"percent": candidate.five_hour.percent,
|
|
502
|
+
"resetsAt": candidate.five_hour.raw_resets_at,
|
|
503
|
+
}
|
|
504
|
+
if candidate.seven_day is not None:
|
|
505
|
+
document["sevenDay"] = {
|
|
506
|
+
"percent": candidate.seven_day.percent,
|
|
507
|
+
"resetsAt": candidate.seven_day.raw_resets_at,
|
|
508
|
+
}
|
|
509
|
+
return document
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _write_candidate(candidate: "_candidates.Candidate") -> None:
|
|
513
|
+
_cctally_core.STATUSLINE_CANDIDATE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
514
|
+
os.chmod(_cctally_core.STATUSLINE_CANDIDATE_DIR, 0o700)
|
|
515
|
+
_atomic_write_json(_candidate_path(candidate.token), _candidate_document(candidate))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _load_candidate_spool(*, now_epoch: int) -> tuple["_candidates.Candidate", ...]:
|
|
519
|
+
directory = _cctally_core.STATUSLINE_CANDIDATE_DIR
|
|
520
|
+
try:
|
|
521
|
+
entries = tuple(directory.iterdir())
|
|
522
|
+
except OSError:
|
|
523
|
+
return ()
|
|
524
|
+
result = []
|
|
525
|
+
for path in entries:
|
|
526
|
+
# Match the complete final grammar before touching an entry. In-flight
|
|
527
|
+
# peer temps are deliberately neither read nor pruned.
|
|
528
|
+
if not _CANDIDATE_FINAL_RE.fullmatch(path.name):
|
|
529
|
+
continue
|
|
530
|
+
try:
|
|
531
|
+
raw = path.read_bytes()
|
|
532
|
+
candidate = _candidates.load_candidate_document(
|
|
533
|
+
raw,
|
|
534
|
+
now_epoch=now_epoch,
|
|
535
|
+
reset_is_plausible=lambda axis, epoch: _statusline_reset_is_plausible(axis, epoch, now_epoch),
|
|
536
|
+
token=path.stem,
|
|
537
|
+
)
|
|
538
|
+
except (OSError, _candidates.StateValidationError):
|
|
539
|
+
try:
|
|
540
|
+
path.unlink()
|
|
541
|
+
except OSError:
|
|
542
|
+
pass
|
|
543
|
+
continue
|
|
544
|
+
if not (-5 <= now_epoch - candidate.received_at < _cctally_core.STATUSLINE_CANDIDATE_TTL_SECONDS):
|
|
545
|
+
try:
|
|
546
|
+
path.unlink()
|
|
547
|
+
except OSError:
|
|
548
|
+
pass
|
|
549
|
+
continue
|
|
550
|
+
result.append(candidate)
|
|
551
|
+
return tuple(result)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _scan_active_candidate_spool(*, now_epoch: int) -> tuple["_candidates.Candidate", ...]:
|
|
555
|
+
"""Read valid active candidates without pruning or otherwise mutating.
|
|
556
|
+
|
|
557
|
+
`doctor` needs a truthful active-count signal but retains its global
|
|
558
|
+
read-only contract. In contrast, the reducer loader above owns best-effort
|
|
559
|
+
cleanup of malformed/expired final entries.
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
entries = tuple(_cctally_core.STATUSLINE_CANDIDATE_DIR.iterdir())
|
|
563
|
+
except OSError:
|
|
564
|
+
return ()
|
|
565
|
+
result = []
|
|
566
|
+
for path in entries:
|
|
567
|
+
if not _CANDIDATE_FINAL_RE.fullmatch(path.name):
|
|
568
|
+
continue
|
|
569
|
+
try:
|
|
570
|
+
candidate = _candidates.load_candidate_document(
|
|
571
|
+
path.read_bytes(),
|
|
572
|
+
now_epoch=now_epoch,
|
|
573
|
+
reset_is_plausible=lambda axis, epoch: _statusline_reset_is_plausible(
|
|
574
|
+
axis, epoch, now_epoch
|
|
575
|
+
),
|
|
576
|
+
token=path.stem,
|
|
577
|
+
)
|
|
578
|
+
except (OSError, _candidates.StateValidationError):
|
|
579
|
+
continue
|
|
580
|
+
if -5 <= now_epoch - candidate.received_at < _cctally_core.STATUSLINE_CANDIDATE_TTL_SECONDS:
|
|
581
|
+
result.append(candidate)
|
|
582
|
+
return tuple(result)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _fingerprint_file(path: pathlib.Path) -> "_candidates.FileFingerprint | None":
|
|
586
|
+
try:
|
|
587
|
+
st = path.stat()
|
|
588
|
+
except FileNotFoundError:
|
|
589
|
+
return None
|
|
590
|
+
except OSError:
|
|
591
|
+
return None
|
|
592
|
+
return _candidates.FileFingerprint(st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _db_file_fingerprint() -> dict[str, "_candidates.FileFingerprint | None"]:
|
|
596
|
+
db = _cctally_core.DB_PATH
|
|
597
|
+
return {
|
|
598
|
+
"main": _fingerprint_file(db),
|
|
599
|
+
"wal": _fingerprint_file(db.with_name(db.name + "-wal")),
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _epoch_from_iso(value: object) -> int:
|
|
604
|
+
return int(_cctally_core.parse_iso_datetime(str(value), "statusline projection").timestamp())
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _read_db_projection_once() -> "_candidates.DbProjection":
|
|
608
|
+
conn = open_db()
|
|
609
|
+
try:
|
|
610
|
+
weekly_rows = conn.execute(
|
|
611
|
+
"SELECT id, weekly_percent, week_start_date, week_start_at, week_end_at, "
|
|
612
|
+
" captured_at_utc, source "
|
|
613
|
+
"FROM weekly_usage_snapshots "
|
|
614
|
+
"WHERE weekly_percent IS NOT NULL AND week_end_at IS NOT NULL "
|
|
615
|
+
"ORDER BY unixepoch(captured_at_utc) DESC, id DESC"
|
|
616
|
+
).fetchall()
|
|
617
|
+
five_rows = conn.execute(
|
|
618
|
+
"SELECT id, five_hour_percent, five_hour_resets_at, five_hour_window_key, "
|
|
619
|
+
" captured_at_utc, source "
|
|
620
|
+
"FROM weekly_usage_snapshots "
|
|
621
|
+
"WHERE five_hour_percent IS NOT NULL AND five_hour_resets_at IS NOT NULL "
|
|
622
|
+
"ORDER BY unixepoch(captured_at_utc) DESC, id DESC"
|
|
623
|
+
).fetchall()
|
|
624
|
+
|
|
625
|
+
weekly_groups: dict[int, list] = {}
|
|
626
|
+
for row in weekly_rows:
|
|
627
|
+
try:
|
|
628
|
+
raw = _epoch_from_iso(row["week_end_at"])
|
|
629
|
+
except (TypeError, ValueError):
|
|
630
|
+
continue
|
|
631
|
+
canonical = int(_cctally_core._normalize_week_boundary_dt(
|
|
632
|
+
dt.datetime.fromtimestamp(raw, tz=dt.timezone.utc)
|
|
633
|
+
).timestamp())
|
|
634
|
+
weekly_groups.setdefault(canonical, []).append(row)
|
|
635
|
+
|
|
636
|
+
weekly_projection = None
|
|
637
|
+
if weekly_groups:
|
|
638
|
+
canonical = max(weekly_groups)
|
|
639
|
+
rows = weekly_groups[canonical]
|
|
640
|
+
reference = max(
|
|
641
|
+
rows, key=lambda row: (_epoch_from_iso(row["captured_at_utc"]), int(row["id"]))
|
|
642
|
+
)
|
|
643
|
+
week_start_at = reference["week_start_at"]
|
|
644
|
+
if not week_start_at:
|
|
645
|
+
week_start_at = dt.datetime.fromtimestamp(
|
|
646
|
+
canonical - 7 * 86400, tz=dt.timezone.utc
|
|
647
|
+
).isoformat().replace("+00:00", "Z")
|
|
648
|
+
week_end_at = reference["week_end_at"]
|
|
649
|
+
floor = _cctally_core._reset_aware_floor(
|
|
650
|
+
conn,
|
|
651
|
+
str(reference["week_start_date"]),
|
|
652
|
+
str(week_start_at),
|
|
653
|
+
str(week_end_at),
|
|
654
|
+
)
|
|
655
|
+
floor_epoch = _epoch_from_iso(floor) if floor else 0
|
|
656
|
+
eligible = [
|
|
657
|
+
row for row in rows
|
|
658
|
+
if floor_epoch == 0 or _epoch_from_iso(row["captured_at_utc"]) >= floor_epoch
|
|
659
|
+
]
|
|
660
|
+
if eligible:
|
|
661
|
+
weekly = max(
|
|
662
|
+
eligible,
|
|
663
|
+
key=lambda row: (_epoch_from_iso(row["captured_at_utc"]), int(row["id"])),
|
|
664
|
+
)
|
|
665
|
+
raw = _epoch_from_iso(weekly["week_end_at"])
|
|
666
|
+
weekly_projection = _candidates.AxisProjection(
|
|
667
|
+
float(weekly["weekly_percent"]), raw, canonical,
|
|
668
|
+
_epoch_from_iso(weekly["captured_at_utc"]),
|
|
669
|
+
str(weekly["source"] or "statusline"), floor_epoch,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
now_epoch = int(time.time())
|
|
673
|
+
five_groups: dict[int, list] = {}
|
|
674
|
+
for row in five_rows:
|
|
675
|
+
try:
|
|
676
|
+
raw = _epoch_from_iso(row["five_hour_resets_at"])
|
|
677
|
+
except (TypeError, ValueError):
|
|
678
|
+
continue
|
|
679
|
+
if not _statusline_reset_is_plausible("fiveHour", raw, now_epoch):
|
|
680
|
+
continue
|
|
681
|
+
stored_key = row["five_hour_window_key"]
|
|
682
|
+
canonical = (
|
|
683
|
+
int(stored_key)
|
|
684
|
+
if stored_key is not None
|
|
685
|
+
else _cctally()._canonical_5h_window_key(raw)
|
|
686
|
+
)
|
|
687
|
+
five_groups.setdefault(canonical, []).append(row)
|
|
688
|
+
|
|
689
|
+
five_projection = None
|
|
690
|
+
if five_groups:
|
|
691
|
+
canonical = max(five_groups)
|
|
692
|
+
reset_event_id = _cctally()._resolve_active_five_hour_reset_event_id(conn, canonical)
|
|
693
|
+
floor_epoch = 0
|
|
694
|
+
if reset_event_id:
|
|
695
|
+
event = conn.execute(
|
|
696
|
+
"SELECT effective_reset_at_utc FROM five_hour_reset_events WHERE id = ?",
|
|
697
|
+
(reset_event_id,),
|
|
698
|
+
).fetchone()
|
|
699
|
+
if event is not None:
|
|
700
|
+
floor_epoch = _epoch_from_iso(event["effective_reset_at_utc"])
|
|
701
|
+
eligible = [
|
|
702
|
+
row for row in five_groups[canonical]
|
|
703
|
+
if floor_epoch == 0 or _epoch_from_iso(row["captured_at_utc"]) >= floor_epoch
|
|
704
|
+
]
|
|
705
|
+
if eligible:
|
|
706
|
+
five = max(
|
|
707
|
+
eligible,
|
|
708
|
+
key=lambda row: (_epoch_from_iso(row["captured_at_utc"]), int(row["id"])),
|
|
709
|
+
)
|
|
710
|
+
raw = _epoch_from_iso(five["five_hour_resets_at"])
|
|
711
|
+
five_projection = _candidates.AxisProjection(
|
|
712
|
+
float(five["five_hour_percent"]), raw, canonical,
|
|
713
|
+
_epoch_from_iso(five["captured_at_utc"]),
|
|
714
|
+
str(five["source"] or "statusline"), int(reset_event_id),
|
|
715
|
+
)
|
|
716
|
+
finally:
|
|
717
|
+
conn.close()
|
|
718
|
+
return _candidates.DbProjection(five_projection, weekly_projection)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _read_db_projection_stable(*, attempts: int = 3) -> "_candidates.DbProjection":
|
|
722
|
+
for _ in range(attempts):
|
|
723
|
+
before = _db_file_fingerprint()
|
|
724
|
+
projection = _read_db_projection_once()
|
|
725
|
+
after = _db_file_fingerprint()
|
|
726
|
+
if before == after and after["main"] is not None:
|
|
727
|
+
return dataclasses.replace(projection, db_files=after)
|
|
728
|
+
raise ProjectionUnstable("stats.db changed during projection")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _fingerprint_document(value: "_candidates.FileFingerprint | None") -> dict | None:
|
|
732
|
+
if value is None:
|
|
733
|
+
return None
|
|
734
|
+
return {"device": value.device, "inode": value.inode, "size": value.size, "mtimeNs": value.mtime_ns}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _axis_projection_document(value: "_candidates.AxisProjection | None") -> dict | None:
|
|
738
|
+
if value is None:
|
|
739
|
+
return None
|
|
740
|
+
return {
|
|
741
|
+
"percent": value.percent,
|
|
742
|
+
"rawResetsAt": value.raw_resets_at,
|
|
743
|
+
"canonicalKey": value.canonical_key,
|
|
744
|
+
"capturedAt": value.captured_at,
|
|
745
|
+
"source": value.source,
|
|
746
|
+
"resetGeneration": value.reset_generation,
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _pending_document(value: "_candidates.PendingDrop | None") -> dict | None:
|
|
751
|
+
if value is None:
|
|
752
|
+
return None
|
|
753
|
+
signature = value.retry_signature
|
|
754
|
+
return {
|
|
755
|
+
"canonicalKey": value.canonical_key,
|
|
756
|
+
"reducedPercent": value.reduced_percent,
|
|
757
|
+
"firstSeenAt": value.first_seen_at,
|
|
758
|
+
"kernelStage": value.kernel_stage,
|
|
759
|
+
"attempts": value.attempts,
|
|
760
|
+
"contributors": {
|
|
761
|
+
token: {"baselineReceivedAt": item.baseline_received_at, "satisfied": item.satisfied}
|
|
762
|
+
for token, item in value.contributors.items()
|
|
763
|
+
},
|
|
764
|
+
"retrySignature": (
|
|
765
|
+
None if signature is None else {
|
|
766
|
+
"candidateKey": signature.candidate_key,
|
|
767
|
+
"candidatePercent": signature.candidate_percent,
|
|
768
|
+
"dbKey": signature.db_key,
|
|
769
|
+
"dbPercent": signature.db_percent,
|
|
770
|
+
"dbResetGeneration": signature.db_reset_generation,
|
|
771
|
+
}
|
|
772
|
+
),
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _control_document(control: "_candidates.ControlState") -> dict:
|
|
777
|
+
files = control.db_projection.db_files or {"main": None, "wal": None}
|
|
778
|
+
return {
|
|
779
|
+
"schemaVersion": 1,
|
|
780
|
+
"dbProjection": {
|
|
781
|
+
"fiveHour": _axis_projection_document(control.db_projection.five_hour),
|
|
782
|
+
"sevenDay": _axis_projection_document(control.db_projection.seven_day),
|
|
783
|
+
},
|
|
784
|
+
"dbFiles": {
|
|
785
|
+
"main": _fingerprint_document(files.get("main")),
|
|
786
|
+
"wal": _fingerprint_document(files.get("wal")),
|
|
787
|
+
},
|
|
788
|
+
"pendingDrops": {
|
|
789
|
+
"fiveHour": _pending_document(control.pending_drops.get("fiveHour")),
|
|
790
|
+
"sevenDay": _pending_document(control.pending_drops.get("sevenDay")),
|
|
791
|
+
},
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _read_control_state(*, now_epoch: int) -> "_candidates.ControlState | None":
|
|
796
|
+
try:
|
|
797
|
+
return _candidates.load_control_document(
|
|
798
|
+
_cctally_core.STATUSLINE_SELECTED_PATH.read_bytes(), now_epoch=now_epoch
|
|
799
|
+
)
|
|
800
|
+
except (OSError, _candidates.StateValidationError):
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _statusline_control_db_agreement(*, now_epoch: int) -> bool | None:
|
|
805
|
+
"""Return control/DB-fingerprint agreement without opening SQLite.
|
|
806
|
+
|
|
807
|
+
``None`` means the derived control document is absent; ``False`` means an
|
|
808
|
+
existing document was invalid or its stable fingerprint is stale. This is
|
|
809
|
+
intentionally stat-only so doctor remains a read-only diagnostic.
|
|
810
|
+
"""
|
|
811
|
+
control = _read_control_state(now_epoch=now_epoch)
|
|
812
|
+
if control is None:
|
|
813
|
+
try:
|
|
814
|
+
return False if _cctally_core.STATUSLINE_SELECTED_PATH.exists() else None
|
|
815
|
+
except OSError:
|
|
816
|
+
return None
|
|
817
|
+
return control.db_projection.db_files == _db_file_fingerprint()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _write_control_state(control: "_candidates.ControlState") -> None:
|
|
821
|
+
_atomic_write_json(_cctally_core.STATUSLINE_SELECTED_PATH, _control_document(control))
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _reconcile_selected_control(
|
|
825
|
+
projection: "_candidates.DbProjection", *, now_epoch: int, observed_axes=()
|
|
826
|
+
) -> None:
|
|
827
|
+
"""Rewrite DB-derived control while retaining only reducer pending state."""
|
|
828
|
+
existing = _read_control_state(now_epoch=now_epoch)
|
|
829
|
+
pending = dict(
|
|
830
|
+
existing.pending_drops
|
|
831
|
+
if existing is not None
|
|
832
|
+
else {"fiveHour": None, "sevenDay": None}
|
|
833
|
+
)
|
|
834
|
+
for axis in observed_axes:
|
|
835
|
+
if axis in _candidates.AXES:
|
|
836
|
+
pending[axis] = None
|
|
837
|
+
_write_control_state(_candidates.ControlState(projection, pending))
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _tombstone_path(axis: str) -> pathlib.Path:
|
|
841
|
+
return (
|
|
842
|
+
_cctally_core.STATUSLINE_AUTHORITATIVE_5H_PATH
|
|
843
|
+
if axis == "fiveHour" else _cctally_core.STATUSLINE_AUTHORITATIVE_7D_PATH
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _tombstone_document(value: "_candidates.Tombstone") -> dict:
|
|
848
|
+
document = {"schemaVersion": 1, "axis": value.axis, "state": value.state}
|
|
849
|
+
if value.state == "inflight":
|
|
850
|
+
document["startedAt"] = value.started_at
|
|
851
|
+
document["priorBlockReceivedAtThrough"] = value.prior_block_received_at_through
|
|
852
|
+
else:
|
|
853
|
+
document["blockReceivedAtThrough"] = value.block_received_at_through
|
|
854
|
+
return document
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _read_tombstone(axis: str, *, now_epoch: int, fail_closed: bool = True) -> "_candidates.Tombstone | None":
|
|
858
|
+
try:
|
|
859
|
+
return _candidates.load_tombstone_document(
|
|
860
|
+
_tombstone_path(axis).read_bytes(), expected_axis=axis, now_epoch=now_epoch
|
|
861
|
+
)
|
|
862
|
+
except FileNotFoundError:
|
|
863
|
+
return None
|
|
864
|
+
except (OSError, _candidates.StateValidationError):
|
|
865
|
+
return _candidates.Tombstone(axis, "inflight") if fail_closed else None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _write_tombstone(axis: str, value: "_candidates.Tombstone") -> None:
|
|
869
|
+
_atomic_write_json(_tombstone_path(axis), _tombstone_document(value))
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _authoritative_begin(axes, *, now_epoch: int) -> dict[str, "_candidates.Tombstone"]:
|
|
873
|
+
"""Write fail-closed inflight tombstones before an authority mutation.
|
|
874
|
+
|
|
875
|
+
Each axis retains a prior committed cutoff while it is inflight. This
|
|
876
|
+
means that a crash after an equality-deduplicated database call has the
|
|
877
|
+
same safety posture as a crash before a write: no stale spool candidate
|
|
878
|
+
can become selected until a later authoritative repair commits it.
|
|
879
|
+
"""
|
|
880
|
+
requested = frozenset(axes)
|
|
881
|
+
if not requested or not requested <= set(_candidates.AXES):
|
|
882
|
+
raise ValueError("authoritative writer requires known observation axes")
|
|
883
|
+
handles = {}
|
|
884
|
+
for axis in sorted(requested):
|
|
885
|
+
previous = _read_tombstone(axis, now_epoch=now_epoch, fail_closed=True)
|
|
886
|
+
if previous is None:
|
|
887
|
+
carried = None
|
|
888
|
+
elif previous.state == "committed":
|
|
889
|
+
carried = previous.block_received_at_through
|
|
890
|
+
else:
|
|
891
|
+
carried = previous.prior_block_received_at_through
|
|
892
|
+
inflight = _candidates.Tombstone(
|
|
893
|
+
axis=axis,
|
|
894
|
+
state="inflight",
|
|
895
|
+
started_at=now_epoch,
|
|
896
|
+
prior_block_received_at_through=carried,
|
|
897
|
+
)
|
|
898
|
+
_write_tombstone(axis, inflight)
|
|
899
|
+
handles[axis] = inflight
|
|
900
|
+
return handles
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _authoritative_commit(
|
|
904
|
+
handles: dict[str, "_candidates.Tombstone"], *, completion_epoch: int
|
|
905
|
+
) -> None:
|
|
906
|
+
"""Finalize every write-ahead tombstone with a monotonic cutoff."""
|
|
907
|
+
for axis, inflight in handles.items():
|
|
908
|
+
cutoff = max(
|
|
909
|
+
inflight.prior_block_received_at_through or 0,
|
|
910
|
+
completion_epoch + _cctally_core.STATUSLINE_CANDIDATE_FUTURE_SKEW_SECONDS,
|
|
911
|
+
)
|
|
912
|
+
_write_tombstone(
|
|
913
|
+
axis,
|
|
914
|
+
_candidates.Tombstone(
|
|
915
|
+
axis=axis,
|
|
916
|
+
state="committed",
|
|
917
|
+
block_received_at_through=cutoff,
|
|
918
|
+
),
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _after_authoritative_record() -> None:
|
|
923
|
+
"""Test seam immediately after the authority kernel returns success."""
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _authoritative_repair_required(*, now_epoch: int) -> bool:
|
|
927
|
+
"""Whether an inflight/invalid tombstone needs an OAuth repair attempt."""
|
|
928
|
+
for axis in _candidates.AXES:
|
|
929
|
+
value = _read_tombstone(axis, now_epoch=now_epoch, fail_closed=True)
|
|
930
|
+
if value is not None and value.state == "inflight":
|
|
931
|
+
return True
|
|
932
|
+
return False
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _authoritative_record_usage(
|
|
936
|
+
args: argparse.Namespace,
|
|
937
|
+
observed_axes,
|
|
938
|
+
*,
|
|
939
|
+
lock_held: bool = False,
|
|
940
|
+
) -> _AuthoritativeRecordResult:
|
|
941
|
+
"""Record OAuth authority under write-ahead tombstones and reconcile it.
|
|
942
|
+
|
|
943
|
+
``lock_held`` is reserved for automatic polling, which holds the selected
|
|
944
|
+
lock across its post-lock freshness/backoff recheck and network request.
|
|
945
|
+
All other callers acquire the same blocking lock here.
|
|
946
|
+
"""
|
|
947
|
+
if not lock_held:
|
|
948
|
+
try:
|
|
949
|
+
with _selected_state_lock():
|
|
950
|
+
return _authoritative_record_usage(
|
|
951
|
+
args, observed_axes, lock_held=True
|
|
952
|
+
)
|
|
953
|
+
except OSError as exc:
|
|
954
|
+
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
955
|
+
|
|
956
|
+
now_epoch = int(time.time())
|
|
957
|
+
try:
|
|
958
|
+
handles = _authoritative_begin(observed_axes, now_epoch=now_epoch)
|
|
959
|
+
except (OSError, ValueError) as exc:
|
|
960
|
+
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
rc = _cctally().cmd_record_usage(args)
|
|
964
|
+
except Exception as exc:
|
|
965
|
+
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
966
|
+
if rc != 0:
|
|
967
|
+
return _AuthoritativeRecordResult("record_failed", f"exit {rc}")
|
|
968
|
+
|
|
969
|
+
try:
|
|
970
|
+
# Intentionally after an equality-deduplicated success: tests use this
|
|
971
|
+
# seam to prove the write-ahead tombstone still fails closed on crash.
|
|
972
|
+
_cctally()._after_authoritative_record()
|
|
973
|
+
projection = _read_db_projection_stable()
|
|
974
|
+
completion_epoch = int(time.time())
|
|
975
|
+
_authoritative_commit(handles, completion_epoch=completion_epoch)
|
|
976
|
+
_reconcile_selected_control(
|
|
977
|
+
projection, now_epoch=completion_epoch, observed_axes=observed_axes
|
|
978
|
+
)
|
|
979
|
+
_cctally()._statusline_observe_touch()
|
|
980
|
+
except Exception as exc:
|
|
981
|
+
# Do not clean up the inflight tombstone on a post-record failure.
|
|
982
|
+
# It is the durable proof that a later authoritative writer must repair
|
|
983
|
+
# this axis before any spool candidate may be selected again.
|
|
984
|
+
return _AuthoritativeRecordResult("record_failed", str(exc))
|
|
985
|
+
return _AuthoritativeRecordResult("ok")
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _empty_control(projection: "_candidates.DbProjection") -> "_candidates.ControlState":
|
|
989
|
+
return _candidates.ControlState(projection, {"fiveHour": None, "sevenDay": None})
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _canonicalize_candidates(
|
|
993
|
+
candidates: tuple["_candidates.Candidate", ...],
|
|
994
|
+
projection: "_candidates.DbProjection",
|
|
995
|
+
) -> tuple["_candidates.Candidate", ...]:
|
|
374
996
|
c = _cctally()
|
|
997
|
+
five_values = [candidate.five_hour for candidate in candidates if candidate.five_hour is not None]
|
|
998
|
+
anchor = None
|
|
999
|
+
if projection.five_hour is not None:
|
|
1000
|
+
anchor = (projection.five_hour.raw_resets_at, projection.five_hour.canonical_key)
|
|
1001
|
+
resolved = _candidates.canonicalize_five_hour_axes(
|
|
1002
|
+
five_values,
|
|
1003
|
+
db_anchor=anchor,
|
|
1004
|
+
canonicalize=lambda raw, prior: c._canonical_5h_window_key(
|
|
1005
|
+
raw,
|
|
1006
|
+
prior_epoch=None if prior is None else prior[0],
|
|
1007
|
+
prior_key=None if prior is None else prior[1],
|
|
1008
|
+
),
|
|
1009
|
+
)
|
|
1010
|
+
# Canonicalization establishes a physical-window key, not a selected
|
|
1011
|
+
# candidate value. Several sessions can share the exact raw reset while
|
|
1012
|
+
# reporting different percentages; mapping raw -> AxisValue would let the
|
|
1013
|
+
# final filesystem enumeration overwrite every session's percentage.
|
|
1014
|
+
by_raw_key = {value.raw_resets_at: value.canonical_key for value in resolved}
|
|
1015
|
+
result = []
|
|
1016
|
+
for candidate in candidates:
|
|
1017
|
+
seven = candidate.seven_day
|
|
1018
|
+
if seven is not None:
|
|
1019
|
+
canonical = int(_cctally_core._normalize_week_boundary_dt(
|
|
1020
|
+
dt.datetime.fromtimestamp(seven.raw_resets_at, tz=dt.timezone.utc)
|
|
1021
|
+
).timestamp())
|
|
1022
|
+
seven = dataclasses.replace(seven, canonical_key=canonical)
|
|
1023
|
+
five = candidate.five_hour
|
|
1024
|
+
if five is not None:
|
|
1025
|
+
five = dataclasses.replace(five, canonical_key=by_raw_key[five.raw_resets_at])
|
|
1026
|
+
result.append(dataclasses.replace(candidate, five_hour=five, seven_day=seven))
|
|
1027
|
+
return tuple(result)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _build_publication_plan(
|
|
1031
|
+
decision: "_candidates.ReductionDecision",
|
|
1032
|
+
projection: "_candidates.DbProjection",
|
|
1033
|
+
*,
|
|
1034
|
+
now_epoch: int,
|
|
1035
|
+
) -> "_candidates.PublicationPlan | None":
|
|
1036
|
+
if decision.plan is None:
|
|
1037
|
+
return None
|
|
1038
|
+
seven = decision.plan.seven_day
|
|
1039
|
+
if seven is None and projection.seven_day is not None:
|
|
1040
|
+
source = projection.seven_day
|
|
1041
|
+
if now_epoch < source.raw_resets_at <= now_epoch + 8 * 86400:
|
|
1042
|
+
seven = _candidates.AxisValue(source.percent, source.raw_resets_at, source.canonical_key)
|
|
1043
|
+
if seven is None:
|
|
1044
|
+
return None
|
|
1045
|
+
five = decision.plan.five_hour
|
|
1046
|
+
if five is None and decision.plan.seven_day is not None and projection.five_hour is not None:
|
|
1047
|
+
source = projection.five_hour
|
|
1048
|
+
if now_epoch < source.raw_resets_at <= now_epoch + 6 * 3600:
|
|
1049
|
+
five = _candidates.AxisValue(source.percent, source.raw_resets_at, source.canonical_key)
|
|
1050
|
+
if five is not None and not (now_epoch < five.raw_resets_at <= now_epoch + 6 * 3600):
|
|
1051
|
+
five = None
|
|
1052
|
+
return _candidates.PublicationPlan(seven_day=seven, five_hour=five)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _projection_changed(before: "_candidates.DbProjection", after: "_candidates.DbProjection") -> bool:
|
|
1056
|
+
return before.five_hour != after.five_hour or before.seven_day != after.seven_day
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _statusline_reduce_and_publish() -> "_candidates.ReductionDecision | None":
|
|
1060
|
+
now_epoch = int(time.time())
|
|
1061
|
+
candidates = _load_candidate_spool(now_epoch=now_epoch)
|
|
1062
|
+
if not candidates:
|
|
1063
|
+
existing = _read_control_state(now_epoch=now_epoch)
|
|
1064
|
+
if existing is not None and any(existing.pending_drops.values()):
|
|
1065
|
+
projection = _read_db_projection_stable()
|
|
1066
|
+
control = _empty_control(projection)
|
|
1067
|
+
_write_control_state(control)
|
|
1068
|
+
return _candidates.ReductionDecision("WRITE_CONTROL", control)
|
|
1069
|
+
return None
|
|
1070
|
+
projection = _read_db_projection_stable()
|
|
1071
|
+
existing = _read_control_state(now_epoch=now_epoch)
|
|
1072
|
+
control_repair_required = (
|
|
1073
|
+
existing is None or existing.db_projection.db_files != projection.db_files
|
|
1074
|
+
)
|
|
1075
|
+
control = (
|
|
1076
|
+
_candidates.ControlState(projection, existing.pending_drops)
|
|
1077
|
+
if existing is not None else _empty_control(projection)
|
|
1078
|
+
)
|
|
1079
|
+
tombstones = {
|
|
1080
|
+
axis: _read_tombstone(axis, now_epoch=now_epoch)
|
|
1081
|
+
for axis in _candidates.AXES
|
|
1082
|
+
}
|
|
1083
|
+
candidates = _canonicalize_candidates(candidates, projection)
|
|
1084
|
+
decision = _candidates.reduce_candidates(
|
|
1085
|
+
candidates, db=projection, control=control, tombstones=tombstones, now_epoch=now_epoch
|
|
1086
|
+
)
|
|
1087
|
+
if decision.action == "NOOP":
|
|
1088
|
+
if control_repair_required:
|
|
1089
|
+
decision = dataclasses.replace(decision, action="WRITE_CONTROL")
|
|
1090
|
+
_write_control_state(decision.control)
|
|
1091
|
+
return decision
|
|
1092
|
+
if decision.action == "WRITE_CONTROL":
|
|
1093
|
+
_write_control_state(decision.control)
|
|
1094
|
+
return decision
|
|
1095
|
+
plan = _build_publication_plan(decision, projection, now_epoch=now_epoch)
|
|
1096
|
+
if plan is None or plan.seven_day is None:
|
|
1097
|
+
_write_control_state(decision.control)
|
|
1098
|
+
return decision
|
|
1099
|
+
args = _record_args(
|
|
1100
|
+
percent=plan.seven_day.percent,
|
|
1101
|
+
resets_at=plan.seven_day.raw_resets_at,
|
|
1102
|
+
five_hour_percent=None if plan.five_hour is None else plan.five_hour.percent,
|
|
1103
|
+
five_hour_resets_at=None if plan.five_hour is None else plan.five_hour.raw_resets_at,
|
|
1104
|
+
source="statusline",
|
|
1105
|
+
)
|
|
1106
|
+
if _cctally().cmd_record_usage(args) != 0:
|
|
1107
|
+
return decision
|
|
1108
|
+
after = _read_db_projection_stable()
|
|
1109
|
+
if _projection_changed(projection, after):
|
|
1110
|
+
# A real DB change is authoritative selected truth, so re-reduce to
|
|
1111
|
+
# clear any satisfied pending axis whose projection now matches it.
|
|
1112
|
+
refreshed = _candidates.reduce_candidates(
|
|
1113
|
+
candidates, db=after, control=decision.control,
|
|
1114
|
+
tombstones=tombstones, now_epoch=int(time.time()),
|
|
1115
|
+
)
|
|
1116
|
+
else:
|
|
1117
|
+
# The record kernel can deliberately leave the DB unchanged: the first
|
|
1118
|
+
# zero arms its own debounce and unsupported small drops are HWM
|
|
1119
|
+
# rejected. Preserve the precomputed bounded attempt state so the next
|
|
1120
|
+
# tick—not this post-record reconciliation—performs the required fresh
|
|
1121
|
+
# contributor-consensus pass before spending another kernel call.
|
|
1122
|
+
refreshed = _candidates.ReductionDecision("WRITE_CONTROL", decision.control)
|
|
1123
|
+
_write_control_state(refreshed.control)
|
|
1124
|
+
if _projection_changed(projection, after):
|
|
1125
|
+
_cctally()._statusline_observe_touch()
|
|
1126
|
+
return refreshed
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _preliminary_decision() -> "_candidates.ReductionDecision | None":
|
|
1130
|
+
now_epoch = int(time.time())
|
|
1131
|
+
candidates = _load_candidate_spool(now_epoch=now_epoch)
|
|
1132
|
+
if not candidates:
|
|
1133
|
+
return None
|
|
1134
|
+
control = _read_control_state(now_epoch=now_epoch)
|
|
1135
|
+
if control is None or control.db_projection.db_files != _db_file_fingerprint():
|
|
1136
|
+
return _candidates.ReductionDecision("WRITE_CONTROL", control or _empty_control(
|
|
1137
|
+
_candidates.DbProjection(None, None, _db_file_fingerprint())
|
|
1138
|
+
))
|
|
1139
|
+
candidates = _canonicalize_candidates(candidates, control.db_projection)
|
|
1140
|
+
return _candidates.reduce_candidates(
|
|
1141
|
+
candidates,
|
|
1142
|
+
db=control.db_projection,
|
|
1143
|
+
control=control,
|
|
1144
|
+
tombstones={axis: _read_tombstone(axis, now_epoch=now_epoch) for axis in _candidates.AXES},
|
|
1145
|
+
now_epoch=now_epoch,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _fork_persist(parent_lock_fd: int) -> None:
|
|
1150
|
+
"""Run the revalidated spool reducer in a detached serialized child."""
|
|
375
1151
|
try:
|
|
376
1152
|
pid = os.fork()
|
|
377
1153
|
except OSError:
|
|
378
|
-
# Out of pids/memory — skip persistence, render fast (spec §2).
|
|
379
1154
|
return
|
|
380
1155
|
if pid > 0:
|
|
381
|
-
# Parent: return at once; the caller's finally releases parent_lock_fd.
|
|
382
1156
|
return
|
|
383
1157
|
|
|
384
1158
|
# --- child ---
|
|
385
1159
|
try:
|
|
386
|
-
# Drop the inherited copy of the parent's lock fd so only the parent's
|
|
387
|
-
# own fd holds the shared lock; the child owns the lock ONLY via the
|
|
388
|
-
# independent fd re-acquired below (avoids a self-block on our own
|
|
389
|
-
# inherited exclusive lock).
|
|
390
1160
|
try:
|
|
391
1161
|
os.close(parent_lock_fd)
|
|
392
1162
|
except OSError:
|
|
@@ -395,7 +1165,6 @@ def _fork_persist(args, parent_lock_fd: int) -> None:
|
|
|
395
1165
|
os.setsid()
|
|
396
1166
|
except OSError:
|
|
397
1167
|
pass
|
|
398
|
-
# Detach stdio immediately so nothing keeps the render's pipe open.
|
|
399
1168
|
try:
|
|
400
1169
|
devnull = os.open(os.devnull, os.O_RDWR)
|
|
401
1170
|
os.dup2(devnull, 0)
|
|
@@ -412,18 +1181,12 @@ def _fork_persist(args, parent_lock_fd: int) -> None:
|
|
|
412
1181
|
_cctally_core.STATUSLINE_PERSIST_LOCK_PATH,
|
|
413
1182
|
os.O_WRONLY | os.O_CREAT, 0o644,
|
|
414
1183
|
)
|
|
415
|
-
# BLOCKING: wait for the parent to release its copy, then hold the
|
|
416
|
-
# lock across record + marker-touch so a concurrent render can't
|
|
417
|
-
# observe a stale marker and double-insert.
|
|
418
1184
|
fcntl.flock(child_fd, fcntl.LOCK_EX)
|
|
419
1185
|
except OSError:
|
|
420
1186
|
child_fd = -1
|
|
421
1187
|
try:
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
c.cmd_record_usage(args)
|
|
425
|
-
# Touch the liveness marker even on a dedup no-op return.
|
|
426
|
-
c._statusline_observe_touch()
|
|
1188
|
+
if child_fd >= 0:
|
|
1189
|
+
_statusline_reduce_and_publish()
|
|
427
1190
|
finally:
|
|
428
1191
|
if child_fd >= 0:
|
|
429
1192
|
try:
|
|
@@ -435,70 +1198,37 @@ def _fork_persist(args, parent_lock_fd: int) -> None:
|
|
|
435
1198
|
except OSError:
|
|
436
1199
|
pass
|
|
437
1200
|
except BaseException:
|
|
438
|
-
# A detached child must never surface a traceback onto the render.
|
|
439
1201
|
pass
|
|
440
1202
|
finally:
|
|
441
1203
|
os._exit(0)
|
|
442
1204
|
|
|
443
1205
|
|
|
444
1206
|
def _statusline_persist(parsed, *, sync_for_test: bool = False) -> None:
|
|
445
|
-
"""
|
|
446
|
-
|
|
447
|
-
Fully guarded — cmd_statusline also wraps this in try/except, and every
|
|
448
|
-
step degrades to a clean no-op. ``sync_for_test=True`` runs the kernel
|
|
449
|
-
INLINE (no fork) so persistence tests are deterministic and no detached
|
|
450
|
-
child outlives fixture cleanup."""
|
|
451
|
-
c = _cctally()
|
|
452
|
-
# 0. Pool-identity guard (spec 2026-07-17 #311 D1). A bracket-variant
|
|
453
|
-
# model id (e.g. `claude-opus-4-8[1m]`) reports a SEPARATE rate-limit
|
|
454
|
-
# pool; persisting it poisons the default-pool DB (HWM latch + dedup
|
|
455
|
-
# freeze). Skip BEFORE the lock/fork AND before touching the
|
|
456
|
-
# observation marker — a foreign-pool session is not evidence the
|
|
457
|
-
# regular-pool pipeline is alive, so the OAuth backfill must keep
|
|
458
|
-
# aging. Render is unchanged (this is persist-only). `.model_id` is a
|
|
459
|
-
# dataclass attr, not a dict key, so this never AttributeErrors into
|
|
460
|
-
# cmd_statusline's silent `except`.
|
|
1207
|
+
"""Spool an eligible session candidate then reduce it opportunistically."""
|
|
461
1208
|
if _lib_statusline.is_alternate_pool_model_id(parsed.model_id):
|
|
462
1209
|
return
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if parsed.rate_limits_7d_pct is None or parsed.rate_limits_7d_resets_at is None:
|
|
1210
|
+
candidate = _candidate_from_input(parsed, received_at=int(time.time()))
|
|
1211
|
+
if candidate is None:
|
|
466
1212
|
return
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
five_pct = None
|
|
475
|
-
five_reset = None
|
|
476
|
-
# 3. Non-blocking cross-process lock — losers render without forking.
|
|
477
|
-
lock_fd = _try_acquire_persist_lock()
|
|
1213
|
+
try:
|
|
1214
|
+
_write_candidate(candidate)
|
|
1215
|
+
_cctally()._statusline_transport_touch()
|
|
1216
|
+
except OSError:
|
|
1217
|
+
return
|
|
1218
|
+
c = _cctally()
|
|
1219
|
+
lock_fd = c._try_acquire_persist_lock()
|
|
478
1220
|
if lock_fd is None:
|
|
479
1221
|
return
|
|
480
1222
|
try:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
throttle = float(_cctally_core.STATUSLINE_PERSIST_THROTTLE_SECONDS)
|
|
484
|
-
if c._statusline_observe_age_seconds() < throttle:
|
|
1223
|
+
preliminary = _preliminary_decision()
|
|
1224
|
+
if preliminary is None or preliminary.action == "NOOP":
|
|
485
1225
|
return
|
|
486
|
-
args = _record_args(
|
|
487
|
-
percent=parsed.rate_limits_7d_pct,
|
|
488
|
-
resets_at=parsed.rate_limits_7d_resets_at,
|
|
489
|
-
five_hour_percent=five_pct,
|
|
490
|
-
five_hour_resets_at=five_reset,
|
|
491
|
-
source="statusline",
|
|
492
|
-
)
|
|
493
|
-
# 5. Run the kernel — detached unless test-sync.
|
|
494
1226
|
if sync_for_test:
|
|
495
|
-
|
|
496
|
-
# Touch the liveness marker even on a dedup no-op return.
|
|
497
|
-
c._statusline_observe_touch()
|
|
1227
|
+
_statusline_reduce_and_publish()
|
|
498
1228
|
return
|
|
499
|
-
_fork_persist(
|
|
1229
|
+
_fork_persist(lock_fd)
|
|
500
1230
|
finally:
|
|
501
|
-
_release_persist_lock(lock_fd)
|
|
1231
|
+
c._release_persist_lock(lock_fd)
|
|
502
1232
|
|
|
503
1233
|
|
|
504
1234
|
def _resolve_context_window(model_id, warn_once) -> "int | None":
|
|
@@ -881,4 +1611,3 @@ def _build_statusline_injections(warn_once):
|
|
|
881
1611
|
context_pct=_context_pct,
|
|
882
1612
|
warn_once=warn_once,
|
|
883
1613
|
)
|
|
884
|
-
|