cctally 1.117.2 → 1.118.1
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 +37 -0
- package/README.md +7 -3
- package/bin/_cctally_cache.py +2873 -193
- package/bin/_cctally_completion.py +106 -33
- package/bin/_cctally_core.py +69 -4
- package/bin/_cctally_dashboard.py +188 -13
- package/bin/_cctally_dashboard_conversation.py +5 -1
- package/bin/_cctally_dashboard_envelope.py +50 -19
- package/bin/_cctally_dashboard_sources.py +23 -1
- package/bin/_cctally_db.py +193 -153
- package/bin/_cctally_diagnosis_sources.py +63 -7
- package/bin/_cctally_doctor.py +865 -93
- package/bin/_cctally_five_hour.py +67 -50
- package/bin/_cctally_forecast.py +35 -13
- package/bin/_cctally_journal.py +186 -18
- package/bin/_cctally_milestone_history.py +859 -62
- package/bin/_cctally_oauth_samples.py +286 -15
- package/bin/_cctally_parser.py +2 -2
- package/bin/_cctally_quota.py +104 -12
- package/bin/_cctally_record.py +153 -29
- package/bin/_cctally_rederive.py +608 -68
- package/bin/_cctally_statusline.py +108 -81
- package/bin/_cctally_store.py +179 -38
- package/bin/_cctally_tui.py +892 -181
- package/bin/_cctally_update.py +32 -13
- package/bin/_lib_codex_conversation_query.py +52 -14
- package/bin/_lib_codex_drain_admission.py +224 -6
- package/bin/_lib_codex_metadata.py +39 -0
- package/bin/_lib_conversation_anon.py +15 -8
- package/bin/_lib_conversation_dispatch.py +7 -0
- package/bin/_lib_conversation_query.py +289 -70
- package/bin/_lib_dashboard_sources.py +37 -1
- package/bin/_lib_detector_access_registry.py +497 -26
- package/bin/_lib_doctor.py +85 -20
- package/bin/_lib_doctor_scan_index.py +321 -0
- package/bin/_lib_ingest_frontier.py +20 -1
- package/bin/_lib_oauth_samples.py +23 -7
- package/bin/_lib_pricing.py +146 -16
- package/bin/_lib_quota.py +19 -0
- package/bin/_lib_rederive.py +11 -6
- package/bin/_lib_retained_records.py +516 -0
- package/bin/_lib_snapshot_cache.py +10 -0
- package/bin/_lib_stale_proof.py +272 -43
- package/bin/_lib_stats_content_revision.py +831 -0
- package/bin/_lib_write_io.py +11 -4
- package/bin/cctally +4 -1
- package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
- package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
- package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
- package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
|
@@ -867,10 +867,7 @@ def _apply_owed_outputs(base, start, output_end, boundary) -> frozenset:
|
|
|
867
867
|
{"lines": 0, "bytes": 0, "decodes": 0}, boundary=boundary)
|
|
868
868
|
elif prefix.cutover_claude is None:
|
|
869
869
|
prefix.cutover_claude = _lib_accounts.UNATTRIBUTED
|
|
870
|
-
|
|
871
|
-
if record is not None:
|
|
872
|
-
_journal._normalize_legacy_account_stamp(
|
|
873
|
-
record, prefix.cutover_claude)
|
|
870
|
+
_install_window_normalizer(prefix, prefix.cutover_claude)
|
|
874
871
|
selection = _lib_journal.resolve_effective_events(
|
|
875
872
|
prefix.slots, protocol_prefix_evidence=tuple(prefix.evidence))
|
|
876
873
|
owed: dict = {}
|
|
@@ -926,13 +923,22 @@ def _apply_owed_outputs(base, start, output_end, boundary) -> frozenset:
|
|
|
926
923
|
# --------------------------------------------------------------------------
|
|
927
924
|
|
|
928
925
|
class _Prefix:
|
|
929
|
-
"""One pass over the journal through the round's target.
|
|
926
|
+
"""One pass over the journal through the round's target.
|
|
927
|
+
|
|
928
|
+
#1004 spec D4: `slots` and `window` are `RetainedRecords` (each record a
|
|
929
|
+
reader retains decoded is a streamed slot, re-read and digest-checked on
|
|
930
|
+
access), and `coordinates` is the window's `LineCoordinates` starts view,
|
|
931
|
+
parallel to `window`. With `MATERIALIZE_FOR_TESTS` they are today's
|
|
932
|
+
lists (a test switch)."""
|
|
930
933
|
|
|
931
934
|
def __init__(self):
|
|
932
|
-
|
|
935
|
+
# selector slots, one per decoded record
|
|
936
|
+
self.slots = _journal._new_retained_records()
|
|
933
937
|
self.evidence: list = [] # protocol-resolution prefix evidence
|
|
934
|
-
|
|
935
|
-
self.
|
|
938
|
+
# window records (placeholders kept)
|
|
939
|
+
self.window = _journal._new_retained_records()
|
|
940
|
+
# parallel to `window`: each position's `(segment, offset)`
|
|
941
|
+
self.coordinates = self.window.coordinates.starts()
|
|
936
942
|
self.excluded_revisions: dict = {}
|
|
937
943
|
self.inert_revisions: dict = {}
|
|
938
944
|
self.begin_at: dict = {}
|
|
@@ -976,6 +982,8 @@ def _window_omits(record) -> bool:
|
|
|
976
982
|
|
|
977
983
|
|
|
978
984
|
def _read_prefix(start, target, boundary) -> _Prefix:
|
|
985
|
+
import _lib_retained_records
|
|
986
|
+
materialize = _lib_retained_records.MATERIALIZE_FOR_TESTS
|
|
979
987
|
out = _Prefix()
|
|
980
988
|
segments = _journal.list_segments()
|
|
981
989
|
hasher = _journal._new_prefix_hasher()
|
|
@@ -1007,13 +1015,13 @@ def _read_prefix(start, target, boundary) -> _Prefix:
|
|
|
1007
1015
|
# compacted before anything holds a reference to it.
|
|
1008
1016
|
record = _lib_journal.compact_retained(record)
|
|
1009
1017
|
if not admitted:
|
|
1010
|
-
out.slots.
|
|
1018
|
+
out.slots.append_retained(None, segment, offset, len(raw))
|
|
1011
1019
|
if record.get("t") in ("evt", "correction"):
|
|
1012
1020
|
_bump(out.excluded_revisions, record)
|
|
1013
1021
|
if in_window:
|
|
1014
|
-
out.window.
|
|
1015
|
-
"record": record, "segment": segment, "offset": offset}}
|
|
1016
|
-
|
|
1022
|
+
out.window.append_retained({**placeholder, "quarantine": {
|
|
1023
|
+
"record": record, "segment": segment, "offset": offset}},
|
|
1024
|
+
segment, offset, len(raw))
|
|
1017
1025
|
prior["hw"] = end
|
|
1018
1026
|
continue
|
|
1019
1027
|
if t == "op":
|
|
@@ -1032,15 +1040,59 @@ def _read_prefix(start, target, boundary) -> _Prefix:
|
|
|
1032
1040
|
out.begin_at.setdefault(record.get("id"), record.get("at"))
|
|
1033
1041
|
out.begins.setdefault(record.get("id"), []).append(record)
|
|
1034
1042
|
out.batch_family.setdefault(record.get("id"), record.get("family"))
|
|
1035
|
-
|
|
1036
|
-
|
|
1043
|
+
if t in _lib_journal_router.RETAINED_RECORD_TYPES:
|
|
1044
|
+
if materialize:
|
|
1045
|
+
out.slots.append_retained(record, segment, offset, len(raw))
|
|
1046
|
+
else:
|
|
1047
|
+
out.slots.append_streamed(raw, segment, offset, len(raw))
|
|
1048
|
+
else:
|
|
1049
|
+
out.slots.append_retained(None, segment, offset, len(raw))
|
|
1037
1050
|
if in_window and not omitted:
|
|
1038
|
-
|
|
1039
|
-
|
|
1051
|
+
if materialize:
|
|
1052
|
+
out.window.append_retained(record, segment, offset, len(raw))
|
|
1053
|
+
else:
|
|
1054
|
+
out.window.append_streamed(raw, segment, offset, len(raw))
|
|
1040
1055
|
prior["hw"] = end
|
|
1056
|
+
if materialize:
|
|
1057
|
+
# Every slot is a retained object here, so this decodes nothing; a
|
|
1058
|
+
# record both lists keep is one shared object, as before.
|
|
1059
|
+
slots, window = out.slots, out.window
|
|
1060
|
+
out.slots, out.window = list(slots), list(window)
|
|
1061
|
+
out.coordinates = list(window.coordinates.starts())
|
|
1062
|
+
slots.close()
|
|
1063
|
+
window.close()
|
|
1041
1064
|
return out
|
|
1042
1065
|
|
|
1043
1066
|
|
|
1067
|
+
def _install_window_normalizer(prefix: _Prefix, cutover_claude) -> None:
|
|
1068
|
+
"""#1004 spec D4: normalize the prefix's retained records under the
|
|
1069
|
+
resolved ``cutover_claude`` at decode time, on both `slots` and `window`,
|
|
1070
|
+
replacing the in-place loop both `_apply_owed_outputs` and
|
|
1071
|
+
`_complete_window` ran before any consumer read a record.
|
|
1072
|
+
|
|
1073
|
+
The loop normalized the selector slots, which are the retained decision
|
|
1074
|
+
records (`RETAINED_RECORD_TYPES`), and with them the very same objects
|
|
1075
|
+
the window shares; a window observation was normalized only in each
|
|
1076
|
+
replay's private copy (`_normalized_copy`). The hook keeps exactly that
|
|
1077
|
+
scope on both sequences. Normalization is idempotent and a pure function
|
|
1078
|
+
of the record and the cutover, so every decode yields an equal record."""
|
|
1079
|
+
if isinstance(prefix.slots, list):
|
|
1080
|
+
# MATERIALIZE_FOR_TESTS: today's lists and today's loop.
|
|
1081
|
+
for record in prefix.slots:
|
|
1082
|
+
if record is not None:
|
|
1083
|
+
_journal._normalize_legacy_account_stamp(
|
|
1084
|
+
record, cutover_claude)
|
|
1085
|
+
return
|
|
1086
|
+
retained_types = _lib_journal_router.RETAINED_RECORD_TYPES
|
|
1087
|
+
|
|
1088
|
+
def normalize(record) -> None:
|
|
1089
|
+
if record.get("t") in retained_types:
|
|
1090
|
+
_journal._normalize_legacy_account_stamp(record, cutover_claude)
|
|
1091
|
+
|
|
1092
|
+
prefix.slots.set_normalizer(normalize)
|
|
1093
|
+
prefix.window.set_normalizer(normalize)
|
|
1094
|
+
|
|
1095
|
+
|
|
1044
1096
|
def _held_observation_ids(slots) -> frozenset:
|
|
1045
1097
|
"""The observations a retained reviewed weekly decision holds (latest
|
|
1046
1098
|
decision per observation wins, as the rederive planner reads them)."""
|
|
@@ -1110,17 +1162,32 @@ def _no_cache_read_adapters():
|
|
|
1110
1162
|
setattr(cctally, name, value)
|
|
1111
1163
|
|
|
1112
1164
|
|
|
1113
|
-
def _normalized_copy(record, cutover_claude):
|
|
1165
|
+
def _normalized_copy(record, cutover_claude, *, fresh=False):
|
|
1114
1166
|
"""One window record as a replay sees it: a private deep copy, its legacy
|
|
1115
1167
|
account stamp normalized under ``cutover_claude`` (an excluded placeholder
|
|
1116
1168
|
is only copied). Reads only this record and the journal-wide cutover
|
|
1117
|
-
value (#997 spec D5).
|
|
1118
|
-
|
|
1169
|
+
value (#997 spec D5).
|
|
1170
|
+
|
|
1171
|
+
``fresh`` (#1004 spec D4): ``record`` is a streamed slot's own decode,
|
|
1172
|
+
already private, so it is normalized in place and returned without the
|
|
1173
|
+
copy (the copy of a private dict equals it)."""
|
|
1174
|
+
out = record if fresh else copy.deepcopy(record)
|
|
1119
1175
|
if out.get("t") != _lib_account_protocol.EXCLUDED_PLACEHOLDER_TYPE:
|
|
1120
1176
|
_journal._normalize_legacy_account_stamp(out, cutover_claude)
|
|
1121
1177
|
return out
|
|
1122
1178
|
|
|
1123
1179
|
|
|
1180
|
+
def _retained_window_ids(window) -> "frozenset | None":
|
|
1181
|
+
"""The identities of the window's retained objects (its placeholders),
|
|
1182
|
+
or None for a list window, all of whose records are retained. A retained
|
|
1183
|
+
object lives as long as the window, so no fresh decode offered while
|
|
1184
|
+
the window is alive can share its id."""
|
|
1185
|
+
if isinstance(window, list):
|
|
1186
|
+
return None
|
|
1187
|
+
return frozenset(id(window.slot(i)) for i in range(len(window))
|
|
1188
|
+
if not window.is_streamed(i))
|
|
1189
|
+
|
|
1190
|
+
|
|
1124
1191
|
def _replay(base, tmp, name, prefix, held_ids, cache_conn, *, retention,
|
|
1125
1192
|
skip=frozenset()):
|
|
1126
1193
|
"""One scratch replay of the window from the predecessor state, the
|
|
@@ -1132,6 +1199,7 @@ def _replay(base, tmp, name, prefix, held_ids, cache_conn, *, retention,
|
|
|
1132
1199
|
copy_path = tmp / name
|
|
1133
1200
|
shutil.copyfile(base, copy_path)
|
|
1134
1201
|
cutover_claude = prefix.cutover_claude
|
|
1202
|
+
retained = _retained_window_ids(prefix.window)
|
|
1135
1203
|
by_input: dict = {}
|
|
1136
1204
|
conn = _cctally_core.open_db(_target_path=str(copy_path))
|
|
1137
1205
|
try:
|
|
@@ -1143,7 +1211,9 @@ def _replay(base, tmp, name, prefix, held_ids, cache_conn, *, retention,
|
|
|
1143
1211
|
# offered, never the whole window up front.
|
|
1144
1212
|
events = _cctally_rederive.replay_scratch_records(
|
|
1145
1213
|
conn, prefix.window, cache_conn, fold=fold, held_ids=held_ids,
|
|
1146
|
-
prepare=lambda record: _normalized_copy(
|
|
1214
|
+
prepare=lambda record: _normalized_copy(
|
|
1215
|
+
record, cutover_claude,
|
|
1216
|
+
fresh=retained is not None and id(record) not in retained),
|
|
1147
1217
|
coordinates=prefix.coordinates, skip_indexes=skip,
|
|
1148
1218
|
on_input=lambda index, derived: by_input.__setitem__(
|
|
1149
1219
|
index, list(derived)),
|
|
@@ -1209,12 +1279,15 @@ def _legacy_decided_inputs(prefix, by_input=None) -> frozenset:
|
|
|
1209
1279
|
previous-release reading closed at that second, and treating that retry
|
|
1210
1280
|
as the reading's own history left the reading's decision unjournaled."""
|
|
1211
1281
|
legacy = prefix.legacy_origin_ids
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1282
|
+
# One pass (#1004 D4: each access decodes a streamed record, so the
|
|
1283
|
+
# legacy inputs are found once rather than by a lookup per input).
|
|
1284
|
+
legacy_inputs = {index: record.get("id")
|
|
1285
|
+
for index, record in enumerate(prefix.window)
|
|
1286
|
+
if _is_legacy_input(record)}
|
|
1287
|
+
out = {index for index, record_id in legacy_inputs.items()
|
|
1288
|
+
if _SNAPSHOT_ID_PREFIX + str(record_id) in legacy}
|
|
1215
1289
|
for index, derived in (by_input or {}).items():
|
|
1216
|
-
if not
|
|
1217
|
-
and _is_legacy_input(prefix.window[index])):
|
|
1290
|
+
if index not in legacy_inputs:
|
|
1218
1291
|
continue
|
|
1219
1292
|
if any(event["id"] in legacy for event in derived
|
|
1220
1293
|
if _lib_rederive._is_owned_event(event)):
|
|
@@ -1253,10 +1326,7 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
|
|
|
1253
1326
|
{"lines": 0, "bytes": 0, "decodes": 0}, boundary=boundary)
|
|
1254
1327
|
elif prefix.cutover_claude is None:
|
|
1255
1328
|
prefix.cutover_claude = _lib_accounts.UNATTRIBUTED
|
|
1256
|
-
|
|
1257
|
-
if record is not None:
|
|
1258
|
-
_journal._normalize_legacy_account_stamp(
|
|
1259
|
-
record, prefix.cutover_claude)
|
|
1329
|
+
_install_window_normalizer(prefix, prefix.cutover_claude)
|
|
1260
1330
|
accumulators: dict = {}
|
|
1261
1331
|
selection = _lib_journal.resolve_effective_events(
|
|
1262
1332
|
prefix.slots, protocol_prefix_evidence=tuple(prefix.evidence),
|
|
@@ -1290,20 +1360,23 @@ def _complete_window(base, tmp, start, target, boundary, round_index, *,
|
|
|
1290
1360
|
return False
|
|
1291
1361
|
_lib_rebuild_progress.phase(
|
|
1292
1362
|
"completion:replay-without", round=round_index)
|
|
1293
|
-
|
|
1363
|
+
# #1004: only the events are kept. A discarded `by_input` bound to a
|
|
1364
|
+
# local would stay alive through the next replay (≈ 24 MB at 195 k
|
|
1365
|
+
# inputs, measured by T11's residual census).
|
|
1366
|
+
events_a = _replay(
|
|
1294
1367
|
base, tmp, f"without-{round_index}.db", prefix, held_ids,
|
|
1295
1368
|
cache_conn, skip=gap,
|
|
1296
|
-
retention=_retention(effective, prefix, owed, legacy=legacy))
|
|
1369
|
+
retention=_retention(effective, prefix, owed, legacy=legacy))[0]
|
|
1297
1370
|
reference: dict = {}
|
|
1298
1371
|
for event in events_a:
|
|
1299
1372
|
reference.setdefault(
|
|
1300
1373
|
event["id"], _lib_journal.event_content_hash(event))
|
|
1301
1374
|
_lib_rebuild_progress.phase(
|
|
1302
1375
|
"completion:replay-with", round=round_index)
|
|
1303
|
-
events_b
|
|
1376
|
+
events_b = _replay(
|
|
1304
1377
|
base, tmp, f"with-{round_index}.db", prefix, held_ids, cache_conn,
|
|
1305
1378
|
retention=_retention(effective, prefix, owed, legacy=legacy,
|
|
1306
|
-
reference=reference))
|
|
1379
|
+
reference=reference))[0]
|
|
1307
1380
|
with_gap = _owned_by_id(events_b)
|
|
1308
1381
|
without_gap = _owned_by_id(events_a)
|
|
1309
1382
|
|
package/bin/_cctally_core.py
CHANGED
|
@@ -599,7 +599,16 @@ _init_paths_from_env()
|
|
|
599
599
|
# `outcome_quarantine_episodes`, `outcome_quarantine_bounds` and
|
|
600
600
|
# `outcome_quarantine_axis_state`, so an index built without them is not
|
|
601
601
|
# current. No numbered migration; `DERIVATION_FRONTIER_EPOCH` is unchanged.
|
|
602
|
-
|
|
602
|
+
# 1023 -> 1024 (#1018 twin spec 5.1): current-week freshness from confirming
|
|
603
|
+
# weekly readings. `claude_weekly_observation_index` gains `weekly_percent`,
|
|
604
|
+
# `weekly_resets_at_utc`, `weekly_reading_genuine` and
|
|
605
|
+
# `weekly_freshness_eligible`, and the partial covering index
|
|
606
|
+
# `idx_claude_weekly_observation_index_freshness` serves the confirmation
|
|
607
|
+
# lookup, so an index built without them is not current. The index
|
|
608
|
+
# population and `captured_at_utc` are unchanged. No numbered migration;
|
|
609
|
+
# `DERIVATION_FRONTIER_EPOCH` is unchanged.
|
|
610
|
+
# Bump checklist (partners, regenerate commands, tripwires): tests/test_stats_index_epoch_pin.py.
|
|
611
|
+
STATS_INDEX_EPOCH = 1024
|
|
603
612
|
LEGACY_STATS_HEAD = 13
|
|
604
613
|
|
|
605
614
|
|
|
@@ -1071,6 +1080,16 @@ _STATS_INGEST_LOCK_HELD = contextvars.ContextVar(
|
|
|
1071
1080
|
_STATS_INTERRUPTED_RECOVERY_SUPPRESSED = contextvars.ContextVar(
|
|
1072
1081
|
"cctally_stats_interrupted_recovery_suppressed", default=0
|
|
1073
1082
|
)
|
|
1083
|
+
#: #1005: the active diagnostic maintenance-wait allowance, or None outside a
|
|
1084
|
+
#: `_cctally_store.bounded_diagnostic_waits` scope. The value is a mutable
|
|
1085
|
+
#: record with one attribute, `remaining_s`, shared by every nested scope and
|
|
1086
|
+
#: charged by `_cctally_store.diagnostic_flock`. Kept here for the same reason
|
|
1087
|
+
#: as the two above: a store-owned ContextVar would be replaced by an empty one
|
|
1088
|
+
#: when `load_script()` reloads the siblings, and a scope entered before the
|
|
1089
|
+
#: reload would silently stop bounding.
|
|
1090
|
+
_STATS_DIAGNOSTIC_WAIT_ALLOWANCE = contextvars.ContextVar(
|
|
1091
|
+
"cctally_stats_diagnostic_wait_allowance", default=None
|
|
1092
|
+
)
|
|
1074
1093
|
|
|
1075
1094
|
|
|
1076
1095
|
def note_stats_maintenance_acquired() -> None:
|
|
@@ -2413,6 +2432,10 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2413
2432
|
conn, _STATS_MIGRATIONS, "stats.db"
|
|
2414
2433
|
)
|
|
2415
2434
|
_reconcile_incomplete_quota_projection(conn)
|
|
2435
|
+
# #1050 §4.3: a `current` store costs two reads and no lock; an
|
|
2436
|
+
# index published by an older binary (or never installed) gets
|
|
2437
|
+
# its content-revision objects here, without an epoch bump.
|
|
2438
|
+
_ensure_stats_content_revision(conn, live=_target_path is None)
|
|
2416
2439
|
return conn
|
|
2417
2440
|
if _target_path is None:
|
|
2418
2441
|
if _uv > LEGACY_STATS_HEAD:
|
|
@@ -2474,6 +2497,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2474
2497
|
_reconcile_durable_applied_migration_errors(
|
|
2475
2498
|
conn, _STATS_MIGRATIONS, "stats.db"
|
|
2476
2499
|
)
|
|
2500
|
+
_ensure_stats_content_revision(conn, live=_target_path is None)
|
|
2477
2501
|
return conn
|
|
2478
2502
|
# PR-3: everything below is pending schema, migration or cutover work;
|
|
2479
2503
|
# it sorts under FILE, and the writer temp store is selected again
|
|
@@ -2876,12 +2900,25 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2876
2900
|
# credit detection, never pruned. `account_key` is the account live
|
|
2877
2901
|
# ingest reads after the cutover-aware legacy normalization;
|
|
2878
2902
|
# `captured_at_utc` is canonical full-precision UTC.
|
|
2903
|
+
#
|
|
2904
|
+
# #1018 twin epoch 1024 (spec 5.1): the record's raw weekly value, its
|
|
2905
|
+
# nearest-hour normalized reset (the second-precision spelling of
|
|
2906
|
+
# `claude_oauth_samples.weekly_resets_at_utc`, used only for
|
|
2907
|
+
# equality), whether it is a genuine weekly reading for this row's
|
|
2908
|
+
# account, and whether it may confirm a displayed weekly value
|
|
2909
|
+
# (genuine, both values present, captured strictly before the reset,
|
|
2910
|
+
# not held by the effective reviewed decision). The partial index
|
|
2911
|
+
# covers the one confirmation lookup.
|
|
2879
2912
|
conn.execute(
|
|
2880
2913
|
"""
|
|
2881
2914
|
CREATE TABLE IF NOT EXISTS claude_weekly_observation_index (
|
|
2882
|
-
observation_id
|
|
2883
|
-
account_key
|
|
2884
|
-
captured_at_utc
|
|
2915
|
+
observation_id TEXT PRIMARY KEY,
|
|
2916
|
+
account_key TEXT NOT NULL,
|
|
2917
|
+
captured_at_utc TEXT NOT NULL,
|
|
2918
|
+
weekly_percent REAL,
|
|
2919
|
+
weekly_resets_at_utc TEXT,
|
|
2920
|
+
weekly_reading_genuine INTEGER NOT NULL DEFAULT 0,
|
|
2921
|
+
weekly_freshness_eligible INTEGER NOT NULL DEFAULT 0
|
|
2885
2922
|
)
|
|
2886
2923
|
"""
|
|
2887
2924
|
)
|
|
@@ -2889,6 +2926,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
2889
2926
|
"CREATE INDEX IF NOT EXISTS idx_claude_weekly_observation_index_window "
|
|
2890
2927
|
"ON claude_weekly_observation_index (account_key, captured_at_utc)"
|
|
2891
2928
|
)
|
|
2929
|
+
conn.execute(
|
|
2930
|
+
"CREATE INDEX IF NOT EXISTS "
|
|
2931
|
+
"idx_claude_weekly_observation_index_freshness "
|
|
2932
|
+
"ON claude_weekly_observation_index "
|
|
2933
|
+
"(account_key, weekly_resets_at_utc, weekly_percent, "
|
|
2934
|
+
"captured_at_utc DESC) WHERE weekly_freshness_eligible = 1"
|
|
2935
|
+
)
|
|
2892
2936
|
# Every decision of every reviewed weekly op (not only the latest),
|
|
2893
2937
|
# with the DECODED disposition: a `weekly_axis_retractions` member is
|
|
2894
2938
|
# `retract`. Idempotent under the primary key.
|
|
@@ -3903,10 +3947,31 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
3903
3947
|
except Exception:
|
|
3904
3948
|
pass
|
|
3905
3949
|
raise
|
|
3950
|
+
# #1050 §4.3: the fresh, scratch and cutover schema apply install
|
|
3951
|
+
# the content-revision objects once the store is at the epoch, so
|
|
3952
|
+
# a scratch replay is tracked from its first row and is published
|
|
3953
|
+
# with them. After the cutover rather than before it: the
|
|
3954
|
+
# cutover's `journal_id` stamping is bookkeeping, and tracking it
|
|
3955
|
+
# would only cost one trigger per legacy row. A legacy store that
|
|
3956
|
+
# a deferred migration keeps below the epoch is not installed.
|
|
3957
|
+
if conn.execute(
|
|
3958
|
+
"PRAGMA user_version"
|
|
3959
|
+
).fetchone()[0] == STATS_INDEX_EPOCH:
|
|
3960
|
+
_ensure_stats_content_revision(
|
|
3961
|
+
conn, live=_target_path is None)
|
|
3906
3962
|
_cctally_store.apply_writer_temp_store(conn)
|
|
3907
3963
|
return conn
|
|
3908
3964
|
|
|
3909
3965
|
|
|
3966
|
+
def _ensure_stats_content_revision(conn, *, live: bool) -> str:
|
|
3967
|
+
"""#1050: install or repair stats.db's content-revision objects (the pure
|
|
3968
|
+
kernel `_lib_stats_content_revision`, a proven leaf). A `current` store is
|
|
3969
|
+
two reads; contention leaves the store as it is for the next open."""
|
|
3970
|
+
import _lib_stats_content_revision
|
|
3971
|
+
return _lib_stats_content_revision.ensure_stats_content_revision(
|
|
3972
|
+
conn, live=live)
|
|
3973
|
+
|
|
3974
|
+
|
|
3910
3975
|
# === WeekRef cluster ================================================
|
|
3911
3976
|
|
|
3912
3977
|
|
|
@@ -2450,6 +2450,108 @@ def _dashboard_sync_loop(
|
|
|
2450
2450
|
sleep(min(0.1, max(0.0, deadline - now)))
|
|
2451
2451
|
|
|
2452
2452
|
|
|
2453
|
+
def _frozen_deferral_settle_loop(
|
|
2454
|
+
*,
|
|
2455
|
+
stop,
|
|
2456
|
+
interval: float,
|
|
2457
|
+
ref,
|
|
2458
|
+
sync_lock,
|
|
2459
|
+
run_locked,
|
|
2460
|
+
mark_rebuilding,
|
|
2461
|
+
is_deferred,
|
|
2462
|
+
monotonic=time.monotonic,
|
|
2463
|
+
wait=None,
|
|
2464
|
+
) -> None:
|
|
2465
|
+
"""Retry a frozen dashboard's no-ingest build until recovery permits it (#932).
|
|
2466
|
+
|
|
2467
|
+
`--no-sync` starts no sync thread, so a frozen dashboard whose first build
|
|
2468
|
+
DEFERRED (an older-epoch index, or a corruption heal running detached) would
|
|
2469
|
+
otherwise hold that data-less deferral frame for its whole life. This loop
|
|
2470
|
+
retries the dashboard's existing locked publisher with ``skip_sync=True``
|
|
2471
|
+
(the same no-ingest build a manual ``POST /api/sync`` runs: no ingest, no
|
|
2472
|
+
OAuth refresh, no derivations) only while the held frame is an EXPLICIT
|
|
2473
|
+
stats deferral (``is_deferred``, the `SyncFailureAttribution.deferred`
|
|
2474
|
+
predicate), and exits for good on any other outcome — a populated or empty
|
|
2475
|
+
success, a crash, an exhausted corruption retry.
|
|
2476
|
+
|
|
2477
|
+
Cadence: one ``interval`` before the first attempt, then
|
|
2478
|
+
``_next_deadline(t0, interval, work)``, the #313 cooldown that bounds duty
|
|
2479
|
+
to 50 %. The ``sync_lock`` acquire is NON-blocking; contention skips that
|
|
2480
|
+
cadence. Every attempt does all of its work under the lock and rechecks
|
|
2481
|
+
``stop`` right after acquiring it, which is what makes
|
|
2482
|
+
`_retire_frozen_deferral_settle`'s lock fence exact. ``wait(seconds)``
|
|
2483
|
+
returns True when stopped (``stop.wait`` in production).
|
|
2484
|
+
"""
|
|
2485
|
+
if wait is None:
|
|
2486
|
+
wait = stop.wait
|
|
2487
|
+
deadline = monotonic() + interval
|
|
2488
|
+
while True:
|
|
2489
|
+
if wait(max(0.0, deadline - monotonic())) or stop.is_set():
|
|
2490
|
+
return
|
|
2491
|
+
if not is_deferred(ref.get()):
|
|
2492
|
+
return # settled elsewhere (a manual sync): no extra build
|
|
2493
|
+
if not sync_lock.acquire(blocking=False):
|
|
2494
|
+
deadline = monotonic() + interval
|
|
2495
|
+
continue
|
|
2496
|
+
try:
|
|
2497
|
+
if stop.is_set() or not is_deferred(ref.get()):
|
|
2498
|
+
return
|
|
2499
|
+
t0 = monotonic()
|
|
2500
|
+
try:
|
|
2501
|
+
with _rebuilding_claim(mark_rebuilding):
|
|
2502
|
+
run_locked(skip_sync=True)
|
|
2503
|
+
except Exception: # noqa: BLE001 — logged; the loop is done
|
|
2504
|
+
_log_sync_iteration_failure()
|
|
2505
|
+
return
|
|
2506
|
+
work = monotonic() - t0
|
|
2507
|
+
still_deferred = is_deferred(ref.get())
|
|
2508
|
+
finally:
|
|
2509
|
+
sync_lock.release()
|
|
2510
|
+
if not still_deferred:
|
|
2511
|
+
return
|
|
2512
|
+
deadline = _next_deadline(t0, interval, work)
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
def _make_frozen_deferral_settle_thread(
|
|
2516
|
+
*, stop, interval, ref, hub, sync_lock, run_sync_now_locked,
|
|
2517
|
+
) -> threading.Thread:
|
|
2518
|
+
"""The daemon thread running `_frozen_deferral_settle_loop` (#932)."""
|
|
2519
|
+
collaborators = _make_sync_loop_collaborators(ref=ref, hub=hub)
|
|
2520
|
+
tui = _cctally()._cctally_tui
|
|
2521
|
+
return threading.Thread(
|
|
2522
|
+
target=lambda: _frozen_deferral_settle_loop(
|
|
2523
|
+
stop=stop,
|
|
2524
|
+
interval=float(interval),
|
|
2525
|
+
ref=ref,
|
|
2526
|
+
sync_lock=sync_lock,
|
|
2527
|
+
run_locked=run_sync_now_locked,
|
|
2528
|
+
mark_rebuilding=collaborators["mark_rebuilding"],
|
|
2529
|
+
is_deferred=tui._tui_snapshot_is_stats_deferral,
|
|
2530
|
+
),
|
|
2531
|
+
daemon=True,
|
|
2532
|
+
name="dashboard-frozen-deferral-settle",
|
|
2533
|
+
)
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
def _retire_frozen_deferral_settle(*, stop, sync_lock, thread) -> None:
|
|
2537
|
+
"""Retire the settle thread before the resources its attempts use (#932).
|
|
2538
|
+
|
|
2539
|
+
A lock fence, not a timed join: set ``stop``, then acquire and release
|
|
2540
|
+
``sync_lock`` (blocking, no timeout), which waits out any attempt already
|
|
2541
|
+
in flight — its build, verifier submission, ``set_final`` and publish all
|
|
2542
|
+
run under the lock — and no attempt can start after it, because each
|
|
2543
|
+
rechecks ``stop`` right after acquiring. The join that follows only
|
|
2544
|
+
collects a thread that is waiting on ``stop`` or returning. The lock is
|
|
2545
|
+
released before the caller retires the memory verifiers, whose unsafe
|
|
2546
|
+
completion takes the same lock. Intentionally unbounded: it drains
|
|
2547
|
+
whichever holder has the lock.
|
|
2548
|
+
"""
|
|
2549
|
+
stop.set()
|
|
2550
|
+
sync_lock.acquire()
|
|
2551
|
+
sync_lock.release()
|
|
2552
|
+
thread.join()
|
|
2553
|
+
|
|
2554
|
+
|
|
2453
2555
|
def _dashboard_startup_schema_migration(*, run_derivations: bool = True) -> None:
|
|
2454
2556
|
"""Advance both stores to head once, at startup, in EVERY mode (#780).
|
|
2455
2557
|
|
|
@@ -7135,6 +7237,25 @@ def _projects_week_usage_rows(
|
|
|
7135
7237
|
return rows
|
|
7136
7238
|
|
|
7137
7239
|
|
|
7240
|
+
def _projects_content_revision_leg(conn: "sqlite3.Connection") -> str:
|
|
7241
|
+
"""The Claude content revision memo leg read on ``conn`` (#1050) for a
|
|
7242
|
+
call without a dispatch leg (tests, the drill-down fallback).
|
|
7243
|
+
|
|
7244
|
+
A ``current`` store's leg is its token. Any other state names itself, so a
|
|
7245
|
+
store with no revision tracking (a bare fixture database) memoizes exactly
|
|
7246
|
+
as it did before #1050 instead of missing on every call; the tick never
|
|
7247
|
+
takes this path, and its own dispatch leg on such a store is a fresh nonce.
|
|
7248
|
+
"""
|
|
7249
|
+
import _lib_stats_content_revision as k
|
|
7250
|
+
try:
|
|
7251
|
+
revision = k.read_claude_content_revision(conn)
|
|
7252
|
+
except k.StatsContentRevisionUnreadable:
|
|
7253
|
+
return "?unreadable"
|
|
7254
|
+
if revision.state == k.STATE_CURRENT:
|
|
7255
|
+
return revision.token
|
|
7256
|
+
return f"?{revision.state}"
|
|
7257
|
+
|
|
7258
|
+
|
|
7138
7259
|
def _build_projects_envelope(
|
|
7139
7260
|
conn: "sqlite3.Connection",
|
|
7140
7261
|
*,
|
|
@@ -7142,6 +7263,7 @@ def _build_projects_envelope(
|
|
|
7142
7263
|
current_week: "Any | None" = None,
|
|
7143
7264
|
weeks_back: int = 12,
|
|
7144
7265
|
use_projects_env_cache: bool = False,
|
|
7266
|
+
content_revision_leg: "str | None" = None,
|
|
7145
7267
|
) -> dict:
|
|
7146
7268
|
"""Build the ``projects.{current_week, trend}`` envelope block.
|
|
7147
7269
|
|
|
@@ -7172,6 +7294,12 @@ def _build_projects_envelope(
|
|
|
7172
7294
|
``(max(session_entries.id), max_wus_id, max(mutation_seq), cw_week_start,
|
|
7173
7295
|
weeks_back)`` — the ``mutation_seq`` leg (#270 §7d) busts the memo on an
|
|
7174
7296
|
id-stable in-place finalization.
|
|
7297
|
+
|
|
7298
|
+
``content_revision_leg`` (#1050 spec 4.5 item 7) is the dashboard tick's
|
|
7299
|
+
dispatch leg of the stats.db Claude content revision, read before this
|
|
7300
|
+
lookup; the memo key carries it, so an in-place ``weekly_percent``
|
|
7301
|
+
correction (which moves no ``MAX(id)``) rebuilds the envelope. ``None``
|
|
7302
|
+
reads it here, on ``conn``.
|
|
7175
7303
|
"""
|
|
7176
7304
|
c = _cctally()
|
|
7177
7305
|
|
|
@@ -7184,6 +7312,7 @@ def _build_projects_envelope(
|
|
|
7184
7312
|
# SELECT uses below).
|
|
7185
7313
|
cur = conn.execute("SELECT COALESCE(MAX(id), 0) FROM session_entries")
|
|
7186
7314
|
max_id = cur.fetchone()[0]
|
|
7315
|
+
stats_readable = True
|
|
7187
7316
|
try:
|
|
7188
7317
|
cur = conn.execute(
|
|
7189
7318
|
"SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
|
|
@@ -7191,6 +7320,15 @@ def _build_projects_envelope(
|
|
|
7191
7320
|
max_wus_id = cur.fetchone()[0]
|
|
7192
7321
|
except sqlite3.OperationalError:
|
|
7193
7322
|
max_wus_id = 0
|
|
7323
|
+
stats_readable = False
|
|
7324
|
+
# #1050 spec 4.5 item 7: the attributed percentages are functions of the
|
|
7325
|
+
# stored `weekly_percent`, which a correction rewrites in place. The leg is
|
|
7326
|
+
# the dispatch's; a caller without one reads it here, before the lookup.
|
|
7327
|
+
# A connection with no stats tables publishes no stats-derived value, so
|
|
7328
|
+
# it keys on no leg; an untrusted or unreadable identity is a fresh nonce.
|
|
7329
|
+
if content_revision_leg is None:
|
|
7330
|
+
content_revision_leg = (
|
|
7331
|
+
_projects_content_revision_leg(conn) if stats_readable else "")
|
|
7194
7332
|
# #270 (§7d, Codex-2b): this OUTER whole-envelope memo returns BEFORE the
|
|
7195
7333
|
# inner per-(project, week) reconcile cache, so an id-stable in-place
|
|
7196
7334
|
# finalization — which leaves MAX(id)/MAX(wus id) flat — would serve the
|
|
@@ -7214,7 +7352,8 @@ def _build_projects_envelope(
|
|
|
7214
7352
|
cw_key: "dt.datetime | None" = None
|
|
7215
7353
|
if current_week is not None:
|
|
7216
7354
|
cw_key = getattr(current_week, "week_start_at", None)
|
|
7217
|
-
memo_key = (max_id, max_wus_id, entry_mutation_seq, cw_key, weeks_back
|
|
7355
|
+
memo_key = (max_id, max_wus_id, entry_mutation_seq, cw_key, weeks_back,
|
|
7356
|
+
content_revision_leg)
|
|
7218
7357
|
cached = _PROJECTS_ENV_MEMO.get("value")
|
|
7219
7358
|
if cached is not None and _PROJECTS_ENV_MEMO.get("key") == memo_key:
|
|
7220
7359
|
return cached
|
|
@@ -12226,7 +12365,6 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
12226
12365
|
c = _cctally()
|
|
12227
12366
|
tui = c._cctally_tui
|
|
12228
12367
|
now_utc = pinned_now or dt.datetime.now(dt.timezone.utc)
|
|
12229
|
-
hydrating = not getattr(args, "no_sync", False)
|
|
12230
12368
|
errors = [f"stats-open: {exc}"]
|
|
12231
12369
|
envelope_precompute = None
|
|
12232
12370
|
try:
|
|
@@ -12249,19 +12387,16 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
12249
12387
|
# a fresh success. There is no earlier success to preserve either.
|
|
12250
12388
|
"last_sync_at": None,
|
|
12251
12389
|
"last_sync_error": "; ".join(errors),
|
|
12252
|
-
"sync_failures": (
|
|
12253
|
-
tui.SyncFailureAttribution(
|
|
12254
|
-
leg="stats-open",
|
|
12255
|
-
database="stats",
|
|
12256
|
-
corruption=tui._stats_open_failure_is_corruption(exc),
|
|
12257
|
-
),
|
|
12258
|
-
),
|
|
12390
|
+
"sync_failures": (tui._tui_deferral_attribution(exc),),
|
|
12259
12391
|
"doctor_payload": doctor_payload,
|
|
12260
12392
|
"envelope_precompute": envelope_precompute,
|
|
12261
|
-
|
|
12393
|
+
# #932 §4.2: hydrating in BOTH modes. A frozen dashboard's settle loop
|
|
12394
|
+
# (`_frozen_deferral_settle_loop`) replaces this frame once recovery
|
|
12395
|
+
# permits a build, so it must read as loading, never as "restart the
|
|
12396
|
+
# dashboard".
|
|
12397
|
+
"hydrating": True,
|
|
12398
|
+
"source_bundle": tui._tui_hydrating_source_bundle(),
|
|
12262
12399
|
}
|
|
12263
|
-
if hydrating:
|
|
12264
|
-
replacements["source_bundle"] = tui._tui_hydrating_source_bundle()
|
|
12265
12400
|
return dataclasses.replace(tui._tui_empty_snapshot(now_utc), **replacements)
|
|
12266
12401
|
|
|
12267
12402
|
|
|
@@ -12351,8 +12486,11 @@ def _dashboard_initial_snapshot_once(
|
|
|
12351
12486
|
)
|
|
12352
12487
|
if conn is not None:
|
|
12353
12488
|
try:
|
|
12489
|
+
reading_errors: list[Exception] = []
|
|
12354
12490
|
try:
|
|
12355
|
-
cw = tui._tui_build_current_week(
|
|
12491
|
+
cw = tui._tui_build_current_week(
|
|
12492
|
+
conn, now_utc, skip_sync=True,
|
|
12493
|
+
reading_errors=reading_errors)
|
|
12356
12494
|
except Exception as exc: # noqa: BLE001 — never block the bind
|
|
12357
12495
|
tui._tui_capture_sync_failure(
|
|
12358
12496
|
conn,
|
|
@@ -12363,6 +12501,18 @@ def _dashboard_initial_snapshot_once(
|
|
|
12363
12501
|
exc=exc,
|
|
12364
12502
|
stats_heal_attempted=stats_heal_attempted,
|
|
12365
12503
|
)
|
|
12504
|
+
# #1018 twin review F1: a failed confirming-reading lookup keeps
|
|
12505
|
+
# the card and is recorded like its other reads.
|
|
12506
|
+
for exc in reading_errors:
|
|
12507
|
+
tui._tui_capture_sync_failure(
|
|
12508
|
+
conn,
|
|
12509
|
+
errors,
|
|
12510
|
+
sync_failures,
|
|
12511
|
+
leg="current-week-reading",
|
|
12512
|
+
database="stats_or_cache",
|
|
12513
|
+
exc=exc,
|
|
12514
|
+
stats_heal_attempted=stats_heal_attempted,
|
|
12515
|
+
)
|
|
12366
12516
|
try:
|
|
12367
12517
|
fc_view = tui._tui_build_forecast_view(
|
|
12368
12518
|
conn, now_utc, skip_sync=True
|
|
@@ -12810,6 +12960,8 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12810
12960
|
update_check_stop = threading.Event()
|
|
12811
12961
|
update_check_thread = _DashboardUpdateCheckThread(
|
|
12812
12962
|
update_check_stop, hub=hub, snapshot_ref=ref, runtime_bind=args.host,
|
|
12963
|
+
# #932 §4.2: serialize its republish with every snapshot publisher.
|
|
12964
|
+
sync_lock=sync_lock,
|
|
12813
12965
|
)
|
|
12814
12966
|
update_check_thread.start()
|
|
12815
12967
|
|
|
@@ -12909,6 +13061,23 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12909
13061
|
file=sys.stderr,
|
|
12910
13062
|
)
|
|
12911
13063
|
|
|
13064
|
+
# #932: a frozen dashboard whose first build DEFERRED (an older-epoch
|
|
13065
|
+
# index or a detached heal) has no sync thread to replace that frame, so a
|
|
13066
|
+
# one-shot settle loop retries the no-ingest build until recovery permits
|
|
13067
|
+
# it. Started after the server objects exist and immediately before the
|
|
13068
|
+
# shutdown-managed region below.
|
|
13069
|
+
frozen_settle_stop = threading.Event()
|
|
13070
|
+
frozen_settle_thread = None
|
|
13071
|
+
if args.no_sync and _cctally()._cctally_tui._tui_snapshot_is_stats_deferral(
|
|
13072
|
+
initial):
|
|
13073
|
+
frozen_settle_thread = _make_frozen_deferral_settle_thread(
|
|
13074
|
+
stop=frozen_settle_stop,
|
|
13075
|
+
interval=float(args.sync_interval),
|
|
13076
|
+
ref=ref, hub=hub, sync_lock=sync_lock,
|
|
13077
|
+
run_sync_now_locked=_run_sync_now_locked,
|
|
13078
|
+
)
|
|
13079
|
+
frozen_settle_thread.start()
|
|
13080
|
+
|
|
12912
13081
|
# Block the main thread until SIGINT/SIGTERM. The wait is driven by a
|
|
12913
13082
|
# self-pipe wakeup fd (signal.set_wakeup_fd) rather than a
|
|
12914
13083
|
# threading.Event, so a single signal unblocks it unconditionally — the
|
|
@@ -12929,6 +13098,12 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
|
12929
13098
|
_lib_write_io.clear_local_instance()
|
|
12930
13099
|
if sync_thread is not None:
|
|
12931
13100
|
sync_thread.stop()
|
|
13101
|
+
# #932: fence the settle thread through `sync_lock` BEFORE the memory
|
|
13102
|
+
# verifiers, their completion locks, the server and the hub retire.
|
|
13103
|
+
if frozen_settle_thread is not None:
|
|
13104
|
+
_retire_frozen_deferral_settle(
|
|
13105
|
+
stop=frozen_settle_stop, sync_lock=sync_lock,
|
|
13106
|
+
thread=frozen_settle_thread)
|
|
12932
13107
|
conversation_sync_stop.set()
|
|
12933
13108
|
if conversation_sync_thread is not None:
|
|
12934
13109
|
conversation_sync_thread.join(timeout=2)
|