ccdrift 0.10.0__py3-none-any.whl
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.
- ccdrift/__init__.py +3 -0
- ccdrift/__main__.py +3 -0
- ccdrift/changelog.py +117 -0
- ccdrift/check.py +339 -0
- ccdrift/cli.py +266 -0
- ccdrift/detector.py +170 -0
- ccdrift/digest.py +83 -0
- ccdrift/draft.py +348 -0
- ccdrift/early.py +143 -0
- ccdrift/failures.py +305 -0
- ccdrift/fields.py +88 -0
- ccdrift/history.py +426 -0
- ccdrift/hooks.py +98 -0
- ccdrift/incidents.py +320 -0
- ccdrift/logs.py +708 -0
- ccdrift/loops.py +146 -0
- ccdrift/notify.py +54 -0
- ccdrift/page.py +310 -0
- ccdrift/replay.py +149 -0
- ccdrift/report.py +303 -0
- ccdrift/schedule.py +461 -0
- ccdrift/sessions.py +349 -0
- ccdrift/settings.py +136 -0
- ccdrift/state.py +118 -0
- ccdrift/status.py +109 -0
- ccdrift/texts.py +218 -0
- ccdrift-0.10.0.dist-info/METADATA +330 -0
- ccdrift-0.10.0.dist-info/RECORD +31 -0
- ccdrift-0.10.0.dist-info/WHEEL +4 -0
- ccdrift-0.10.0.dist-info/entry_points.txt +2 -0
- ccdrift-0.10.0.dist-info/licenses/LICENSE +21 -0
ccdrift/__init__.py
ADDED
ccdrift/__main__.py
ADDED
ccdrift/changelog.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Claude Code's release notes, read from the copy Claude Code keeps on disk
|
|
2
|
+
(`<config dir>/cache/changelog.md`), to explain an alert with what changed in the
|
|
3
|
+
versions around it. Nothing is fetched."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Sequence, Union
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from ccdrift.logs import first_days_by_version
|
|
15
|
+
from ccdrift.texts import CONTROL_CHARS, version_key
|
|
16
|
+
|
|
17
|
+
# Words per topic and their weight. A line is quoted once its words weigh QUOTE_WEIGHT,
|
|
18
|
+
# heaviest first. Checked against Claude Code's own changelog: "model", "tool", "agent"
|
|
19
|
+
# and "context" matched about half of each version's notes, and "mcp" mostly sign-in and
|
|
20
|
+
# menu fixes, so they aren't used; "effort", "transcript" and "usage" alone matched
|
|
21
|
+
# mostly display fixes, so they count only together with another word ("Now defaults
|
|
22
|
+
# to high effort", "reporting as 0 in transcript and result usage").
|
|
23
|
+
# Which release notes explain an alert or a draft about each metric or setting.
|
|
24
|
+
TOPIC_OF = {"cache_ratio": "cache", "haiku_fraction": "haiku", "cache_tier": "cache", "effort": "effort",
|
|
25
|
+
"subagent_model": "subagents"}
|
|
26
|
+
|
|
27
|
+
TOPICS: dict[str, dict[str, int]] = {
|
|
28
|
+
"cache": {"cache": 2, "prompt-cache": 1, "prompt cache": 1, "cache miss": 1, "cache reuse": 1},
|
|
29
|
+
"haiku": {"haiku": 2, "small model": 2, "small-model": 2, "fallback model": 2, "default model": 2},
|
|
30
|
+
"effort": {"effort level": 2, "default effort": 2, "default-effort": 2, "reasoning effort": 2,
|
|
31
|
+
"effortlevel": 2, "thinking budget": 2, "effort": 1, "defaults to": 1},
|
|
32
|
+
"context": {"system prompt": 2, "tool definition": 2, "tool list": 2, "deferred": 2},
|
|
33
|
+
"hooks": {"hook": 2, "stop hook": 1, "hook input": 1},
|
|
34
|
+
"subagents": {"subagent model": 2, "subagent_model": 2},
|
|
35
|
+
"fields": {"session transcript": 2, "transcript file": 2, "transcript writes": 2, "saved transcript": 2,
|
|
36
|
+
"session file": 2, "transcript": 1, "usage": 1},
|
|
37
|
+
"errors": {"api error": 2, "rate limit": 2, "overloaded": 2, "retry": 2, "retries": 2, "max tokens": 2,
|
|
38
|
+
"max_tokens": 2, "output limit": 2, "truncat": 1, "timeout": 1},
|
|
39
|
+
}
|
|
40
|
+
QUOTE_WEIGHT = 2
|
|
41
|
+
NOTES_PER_VERSION = 2
|
|
42
|
+
NOTE_CHARS = 160
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def changelog_path(source: Path) -> Path:
|
|
46
|
+
"""The changelog in the Claude Code config folder that holds `source`."""
|
|
47
|
+
return source.expanduser().parent / "cache" / "changelog.md"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_changelog(path: Path) -> dict[str, list[str]]:
|
|
51
|
+
"""Bullet lines per version from a changelog with `## <version>` headers; empty
|
|
52
|
+
when the file is missing or unreadable. A quoted line is printed to a terminal and
|
|
53
|
+
appended to the check's log, and this file is written by Claude Code rather than by
|
|
54
|
+
ccdrift, so control characters are dropped from it as they are from every other text
|
|
55
|
+
ccdrift reads."""
|
|
56
|
+
try:
|
|
57
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
58
|
+
except OSError:
|
|
59
|
+
return {}
|
|
60
|
+
notes: dict[str, list[str]] = {}
|
|
61
|
+
current = None
|
|
62
|
+
for line in text.splitlines():
|
|
63
|
+
header = re.match(r"^##\s+v?(\d[\w.+-]*)", line)
|
|
64
|
+
if header:
|
|
65
|
+
current = header.group(1)
|
|
66
|
+
notes.setdefault(current, [])
|
|
67
|
+
elif current is not None and line.startswith("- "):
|
|
68
|
+
notes[current].append(CONTROL_CHARS.sub("", line[2:]).strip())
|
|
69
|
+
return notes
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def days_before(day: str, days: int) -> str:
|
|
73
|
+
return (date.fromisoformat(day) - timedelta(days=days)).isoformat()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def new_versions(turns: pd.DataFrame, first_day: str, last_day: str) -> list[str]:
|
|
77
|
+
"""Versions whose first day among `turns` lies in [first_day, last_day], oldest first."""
|
|
78
|
+
if turns.empty or "version" not in turns:
|
|
79
|
+
return []
|
|
80
|
+
first_seen = first_days_by_version(turns)
|
|
81
|
+
return sorted((str(v) for v, day in first_seen.items() if first_day <= day <= last_day), key=version_key)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def note_versions(turns: pd.DataFrame, named: Sequence[str], first_day: str, last_day: str) -> list[str]:
|
|
85
|
+
"""The versions whose release notes an alert quotes: those its message names
|
|
86
|
+
("2.1.267 (since 09-10)"), then the others first seen from `first_day` to
|
|
87
|
+
`last_day`, newest first."""
|
|
88
|
+
new = new_versions(turns, first_day, last_day)
|
|
89
|
+
return list(dict.fromkeys([text.split(" ")[0] for text in named] + new[::-1]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def release_notes(changelog: dict[str, list[str]], versions: Sequence[str],
|
|
93
|
+
topics: Union[str, Sequence[str]], limit: int = 5) -> list[tuple[str, str]]:
|
|
94
|
+
"""Up to `limit` (version, line) pairs from `versions`, in that order, whose text
|
|
95
|
+
mentions words of `topics` weighing QUOTE_WEIGHT or more: at most NOTES_PER_VERSION
|
|
96
|
+
from each version, heaviest first, then in changelog order. Long lines are cut at
|
|
97
|
+
NOTE_CHARS."""
|
|
98
|
+
names = (topics,) if isinstance(topics, str) else tuple(topics)
|
|
99
|
+
weights: dict[str, int] = {}
|
|
100
|
+
for name in names:
|
|
101
|
+
for word, weight in TOPICS[name].items():
|
|
102
|
+
weights[word] = max(weight, weights.get(word, 0))
|
|
103
|
+
found = []
|
|
104
|
+
for version in versions:
|
|
105
|
+
scored = [(sum(weight for word, weight in weights.items() if word in text.lower()), text)
|
|
106
|
+
for text in changelog.get(version, [])]
|
|
107
|
+
lines = [text for score, text in sorted(scored, key=lambda item: -item[0]) if score >= QUOTE_WEIGHT]
|
|
108
|
+
for text in lines[:NOTES_PER_VERSION]:
|
|
109
|
+
found.append((version, text if len(text) <= NOTE_CHARS else text[:NOTE_CHARS - 1] + "…"))
|
|
110
|
+
if len(found) == limit:
|
|
111
|
+
return found
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def note_lines(notes: Sequence[tuple[str, str]]) -> list[str]:
|
|
116
|
+
"""Log lines for an alert."""
|
|
117
|
+
return [f"release notes {version}: {text}" for version, text in notes]
|
ccdrift/check.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""The daily check: follow incidents from their first flagged day until they
|
|
2
|
+
recover, and alert when the check fails or can't compute the cache metric."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import traceback
|
|
8
|
+
from datetime import date, datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from ccdrift.changelog import (TOPIC_OF, changelog_path, days_before, load_changelog, note_lines, note_versions,
|
|
15
|
+
release_notes)
|
|
16
|
+
from ccdrift.detector import DetectorConfig
|
|
17
|
+
from ccdrift.digest import digest_due, digest_week, weekly_digest
|
|
18
|
+
from ccdrift.early import early_message, early_warning
|
|
19
|
+
from ccdrift.failures import (cut_short, cut_short_message, failing_requests, failure_counts, judged_failures,
|
|
20
|
+
requests_message)
|
|
21
|
+
from ccdrift.fields import field_gaps, gap_message
|
|
22
|
+
from ccdrift.history import load_history
|
|
23
|
+
from ccdrift.hooks import failure_message, hook_failures, judged_hook_runs
|
|
24
|
+
from ccdrift.incidents import (RECOVERY_BINS, describe, incident_cost, incident_versions, update_incidents,
|
|
25
|
+
versions_text)
|
|
26
|
+
from ccdrift.logs import judged_turns, no_transcripts_message
|
|
27
|
+
from ccdrift.loops import STREAMS, loop_counts, loop_message, loop_warning
|
|
28
|
+
from ccdrift.notify import notify, run_exec
|
|
29
|
+
from ccdrift.replay import REPLAY_SOURCE, first_run, history_message, replay_incidents
|
|
30
|
+
from ccdrift.sessions import context_alerts, context_message, rejudged, session_starts
|
|
31
|
+
from ccdrift.settings import change_message, setting_changes
|
|
32
|
+
from ccdrift.state import CONTEXT_RULE, ccdrift_home, load_state, record_run, save_state, state_lock
|
|
33
|
+
from ccdrift.texts import LOOP_NAMES, approx
|
|
34
|
+
|
|
35
|
+
# kind, title, message, and lines for the log only
|
|
36
|
+
Alert = tuple[str, str, str, list[str]]
|
|
37
|
+
|
|
38
|
+
# Alerts that only go to the log: nothing changed that the owner can act on.
|
|
39
|
+
LOG_ONLY = frozenset({"context_dropped"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# The alert kind of a tool-loop warning for each stream.
|
|
43
|
+
LOOP_KINDS = {"main": "loop", "subagent": "subagent_loop"}
|
|
44
|
+
|
|
45
|
+
# A stretch of active days without usable cache values means the cache metric
|
|
46
|
+
# can't be computed, most likely because Claude Code's log format changed: it
|
|
47
|
+
# goes blank when prompts aren't recognised, and reads as all misses when cache
|
|
48
|
+
# usage isn't read.
|
|
49
|
+
CHECK_BLANK_DAYS = 3
|
|
50
|
+
CHECK_ACTIVE_RESPONSES = 50 # main-thread responses; the quietest of 29 real days had 81
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def blank_cache_stretch(turns: pd.DataFrame, state: dict[str, Any],
|
|
54
|
+
days: int = CHECK_BLANK_DAYS,
|
|
55
|
+
active: int = CHECK_ACTIVE_RESPONSES) -> Optional[dict[str, Any]]:
|
|
56
|
+
"""The latest run of active days (at least `active` judged responses) on which no
|
|
57
|
+
new-prompt turn has cache token counts, once it is `days` long and wasn't
|
|
58
|
+
reported before; it is recorded in state["blank_cache"], and its last day in
|
|
59
|
+
state["blank_cache_seen"]. Every Claude Code response reads or writes the prompt
|
|
60
|
+
cache, so such days mean the parser has lost track of it."""
|
|
61
|
+
usable = turns["prompt_within_ttl"].astype(bool) & ((turns["cache_read"] + turns["cache_creation"]) > 0)
|
|
62
|
+
per_day = pd.DataFrame({"responses": turns.groupby("day").size(),
|
|
63
|
+
"usable": usable.groupby(turns["day"]).sum()})
|
|
64
|
+
per_day = per_day[per_day["responses"] >= active]
|
|
65
|
+
blank = (per_day["usable"] == 0).to_numpy()
|
|
66
|
+
if len(blank) < days or not blank[-days:].all():
|
|
67
|
+
return None
|
|
68
|
+
start = len(blank) - days
|
|
69
|
+
while start > 0 and blank[start - 1]:
|
|
70
|
+
start -= 1
|
|
71
|
+
stretch = per_day.iloc[start:]
|
|
72
|
+
first = str(stretch.index[0])
|
|
73
|
+
# A stretch reaching back to the earliest day the check read may have begun before
|
|
74
|
+
# it: it is the one already reported when an earlier run saw it go on to that day,
|
|
75
|
+
# or, in a state from before ccdrift kept that day, when one was reported earlier.
|
|
76
|
+
seen = state.get("blank_cache_seen")
|
|
77
|
+
continues = start == 0 and (seen >= first if seen else any(day < first for day in state["blank_cache"]))
|
|
78
|
+
state["blank_cache_seen"] = str(stretch.index[-1])
|
|
79
|
+
if first in state["blank_cache"] or continues:
|
|
80
|
+
return None
|
|
81
|
+
state["blank_cache"].append(first)
|
|
82
|
+
prompts = int(turns.loc[turns["day"].isin(stretch.index), "new_prompt"].sum())
|
|
83
|
+
return {"first": first, "days": len(stretch), "responses": int(stretch["responses"].sum()),
|
|
84
|
+
"prompts": prompts}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# How much history a check reads, so hourly runs don't slow down as the history grows:
|
|
88
|
+
# the last HISTORY_DAYS days, or the last HISTORY_ACTIVE_DAYS days with at least
|
|
89
|
+
# ACTIVE_DAY_RESPONSES main-thread responses when those reach further back (after a
|
|
90
|
+
# break, or for occasional use), reaching back HISTORY_DAYS before any incident whose
|
|
91
|
+
# cost it works out too. The longest look back is a flag within the last 14 days,
|
|
92
|
+
# judged against 14 active days that skip an incident of up to 30 days.
|
|
93
|
+
HISTORY_DAYS = 90
|
|
94
|
+
HISTORY_ACTIVE_DAYS = 60
|
|
95
|
+
ACTIVE_DAY_RESPONSES = 20
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _needs_cost(incident: dict[str, Any]) -> bool:
|
|
99
|
+
"""Whether the check works out an incident's cost and versions for `ccdrift status`:
|
|
100
|
+
on every run while it is open, and once after it is added, closed or dismissed by
|
|
101
|
+
hand, since its days don't change after that."""
|
|
102
|
+
if incident["status"] == "open":
|
|
103
|
+
return True
|
|
104
|
+
by_hand = incident["source"] == "user" or incident["closed_by"] == "user"
|
|
105
|
+
return by_hand and (incident.get("costed_on") or "") < (incident["closed_on"] or "")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def history_start(incidents: list[dict[str, Any]], today: date) -> str:
|
|
109
|
+
"""The UTC day HISTORY_DAYS before today, or before the start of the earliest
|
|
110
|
+
incident whose cost the check works out."""
|
|
111
|
+
starts = [incident["start"] for incident in incidents if _needs_cost(incident)]
|
|
112
|
+
return days_before(min([today.isoformat(), *starts]), HISTORY_DAYS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _change_notes(turns: pd.DataFrame, changelog: dict[str, list[str]], change: dict[str, Any],
|
|
116
|
+
topic: str) -> tuple[list[str], list[str]]:
|
|
117
|
+
"""For a change seen on `change["days"]` from `change["since"]`: the versions behind
|
|
118
|
+
those days, which its message names, and the log lines quoting release notes on
|
|
119
|
+
`topic` from those and the other versions first seen from a week before it."""
|
|
120
|
+
versions = versions_text(turns, change["days"])
|
|
121
|
+
quoted = note_versions(turns, versions, days_before(change["since"], 7), change["days"][-1])
|
|
122
|
+
return versions, note_lines(release_notes(changelog, quoted, topic))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _alerts(source: Path, state_path: Path, state: dict[str, Any], cfg: DetectorConfig,
|
|
126
|
+
today: date, now: datetime, digest: bool) -> list[Alert]:
|
|
127
|
+
"""Everything that changed since the last run, in the order alerts go out;
|
|
128
|
+
`state` is updated to match."""
|
|
129
|
+
tables = load_history(source, state_path, claim=True, since=history_start(state["incidents"], today),
|
|
130
|
+
active_days=HISTORY_ACTIVE_DAYS, active_responses=ACTIVE_DAY_RESPONSES)
|
|
131
|
+
df = tables.responses
|
|
132
|
+
if df.empty:
|
|
133
|
+
raise RuntimeError(no_transcripts_message(source))
|
|
134
|
+
turns = judged_turns(df, today)
|
|
135
|
+
changelog = load_changelog(changelog_path(source))
|
|
136
|
+
incidents = state["incidents"]
|
|
137
|
+
alerts: list[Alert] = []
|
|
138
|
+
# A first check replays its history day by day instead, so a regression that began
|
|
139
|
+
# more than 14 days ago is recorded too; its last replayed day is today.
|
|
140
|
+
replaying = first_run(state)
|
|
141
|
+
events = [] if replaying else update_incidents(turns, state, today, cfg)
|
|
142
|
+
if replaying:
|
|
143
|
+
replayed = replay_incidents(df, today, cfg, state)
|
|
144
|
+
found = sorted((i for i in incidents if i["source"] == REPLAY_SOURCE), key=lambda i: i["start"])
|
|
145
|
+
if found:
|
|
146
|
+
notes = []
|
|
147
|
+
judged_days = sorted(turns["day"].astype(str).unique())
|
|
148
|
+
for incident in found:
|
|
149
|
+
if incident["status"] != "persistent":
|
|
150
|
+
# As its flag alert would have: versions first seen from a week before
|
|
151
|
+
# the incident through its first RECOVERY_BINS days.
|
|
152
|
+
first_days = [day for day in judged_days if day >= incident["start"]][:RECOVERY_BINS]
|
|
153
|
+
quoted = note_versions(turns, incident["versions"], days_before(incident["start"], 7),
|
|
154
|
+
first_days[-1])
|
|
155
|
+
notes += release_notes(changelog, quoted, TOPIC_OF[incident["metric"]])
|
|
156
|
+
alerts.append(("history", "ccdrift: past incidents found",
|
|
157
|
+
history_message(found, str(turns["day"].min())), note_lines(notes)))
|
|
158
|
+
# A regression still going is why someone installs ccdrift mid-flight, and the
|
|
159
|
+
# summary above reads as history. It also gets the flag alert it would have had,
|
|
160
|
+
# with the days and z values that opened it; the release notes stay on the summary
|
|
161
|
+
# rather than being quoted twice in the same run.
|
|
162
|
+
for _, event in replayed:
|
|
163
|
+
if event.kind == "flag" and event.incident["status"] == "open":
|
|
164
|
+
kind, title, message, details, _ = describe(event, turns, incidents, cfg)
|
|
165
|
+
alerts.append((kind, title, message, details))
|
|
166
|
+
for event in events:
|
|
167
|
+
kind, title, message, details, named = describe(event, turns, incidents, cfg)
|
|
168
|
+
notes = []
|
|
169
|
+
if event.kind != "persistent" and event.days:
|
|
170
|
+
first = days_before(event.incident["start"], 7) if event.kind == "flag" else event.incident["start"]
|
|
171
|
+
notes = release_notes(changelog, note_versions(turns, named, first, max(event.days)),
|
|
172
|
+
TOPIC_OF[event.incident["metric"]])
|
|
173
|
+
alerts.append((kind, title, message, details + note_lines(notes)))
|
|
174
|
+
warning = early_warning(df, incidents, state, now)
|
|
175
|
+
if warning:
|
|
176
|
+
alerts.append(("early", "ccdrift: cache misses rising", early_message(warning, now), []))
|
|
177
|
+
for stream in STREAMS:
|
|
178
|
+
loop = loop_warning(df, stream, state, now)
|
|
179
|
+
if loop:
|
|
180
|
+
since, alarm_day = loop["since"][:10], loop["at"][:10]
|
|
181
|
+
quoted = note_versions(turns, loop["versions"], days_before(since, 7), alarm_day)
|
|
182
|
+
alerts.append((LOOP_KINDS[stream], f"ccdrift: {LOOP_NAMES[stream]}", loop_message(loop, now),
|
|
183
|
+
note_lines(release_notes(changelog, quoted, "cache"))))
|
|
184
|
+
# `ccdrift status` reads only the state file, so it shows the cost and versions
|
|
185
|
+
# saved here: for open incidents, and for incidents added or closed by hand, which
|
|
186
|
+
# start without a cost or keep the one from before they were closed. describe()
|
|
187
|
+
# has just refreshed the incidents it alerted about.
|
|
188
|
+
described = {id(event.incident) for event in events}
|
|
189
|
+
for incident in incidents:
|
|
190
|
+
if id(incident) in described or not _needs_cost(incident):
|
|
191
|
+
continue
|
|
192
|
+
incident["cost"] = round(incident_cost(turns, incident, incidents, cfg))
|
|
193
|
+
if incident["status"] != "open":
|
|
194
|
+
incident["costed_on"] = today.isoformat()
|
|
195
|
+
if incident["source"] == "user" and not incident["versions"]:
|
|
196
|
+
incident["versions"] = incident_versions(turns, incident)
|
|
197
|
+
for change in setting_changes(turns, state, today):
|
|
198
|
+
versions, notes = _change_notes(turns, changelog, change, TOPIC_OF[change["setting"]])
|
|
199
|
+
alerts.append(("setting", "ccdrift: setting changed", change_message(change, versions), notes))
|
|
200
|
+
starts = session_starts(df)
|
|
201
|
+
for change in context_alerts(starts, state, today):
|
|
202
|
+
versions, notes = _change_notes(turns, changelog, change, "context")
|
|
203
|
+
alerts.append(("context", "ccdrift: session start changed", context_message(change, versions), notes))
|
|
204
|
+
# A state written before the rule judged each project against itself may hold changes
|
|
205
|
+
# that were only a move between projects. They are re-judged once, and the run says so
|
|
206
|
+
# in its log without alerting: nothing changed for the owner to act on.
|
|
207
|
+
if state.get("context_rule", 1) < CONTEXT_RULE:
|
|
208
|
+
for record in rejudged(starts, state, today):
|
|
209
|
+
alerts.append(("context_dropped", "ccdrift: a recorded session-start change was dropped",
|
|
210
|
+
f"{record['since']}, ~{approx(record['from'])} -> ~{approx(record['to'])} tokens: "
|
|
211
|
+
"judged against each project's own level, it isn't a change.", []))
|
|
212
|
+
state["context_rule"] = CONTEXT_RULE
|
|
213
|
+
for failure in hook_failures(judged_hook_runs(tables.hook_runs, today), state, today):
|
|
214
|
+
versions, notes = _change_notes(turns, changelog, failure, "hooks")
|
|
215
|
+
alerts.append(("hooks", "ccdrift: hooks failing", failure_message(failure, versions), notes))
|
|
216
|
+
counts = failure_counts(judged_failures(tables.failures, today), turns)
|
|
217
|
+
for episode in failing_requests(counts, state, today):
|
|
218
|
+
versions, notes = _change_notes(turns, changelog, episode, "errors")
|
|
219
|
+
alerts.append(("failed_requests", "ccdrift: requests failing", requests_message(episode, versions), notes))
|
|
220
|
+
for episode in cut_short(counts, state, today):
|
|
221
|
+
versions, notes = _change_notes(turns, changelog, episode, "errors")
|
|
222
|
+
alerts.append(("cut_short", "ccdrift: responses cut short", cut_short_message(episode, versions), notes))
|
|
223
|
+
for gap in field_gaps(turns, state, today):
|
|
224
|
+
notes = release_notes(changelog, [] if gap["version"] == "unknown" else [gap["version"]], "fields")
|
|
225
|
+
alerts.append(("fields", "ccdrift: Claude Code stopped logging a field", gap_message(gap), note_lines(notes)))
|
|
226
|
+
blank = blank_cache_stretch(turns, state)
|
|
227
|
+
if blank:
|
|
228
|
+
alerts.append(("blank_cache", "ccdrift can't compute the cache metric",
|
|
229
|
+
f"no usable cache values on {blank['days']} active days from {blank['first']} "
|
|
230
|
+
f"({blank['responses']} responses, {blank['prompts']} prompts recognised). "
|
|
231
|
+
"Claude Code's log format may have changed; run `ccdrift peek`.", []))
|
|
232
|
+
week_start = digest_due(state, now) if digest else None
|
|
233
|
+
if week_start is not None:
|
|
234
|
+
state["digest_week"] = digest_week(now)
|
|
235
|
+
alerts.append(("digest", "ccdrift: weekly summary",
|
|
236
|
+
weekly_digest(turns, state, week_start, loop_counts(df, today), counts), []))
|
|
237
|
+
return alerts
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _run_on_state(source: Path, state_path: Path, cfg: DetectorConfig, today: date, started: datetime,
|
|
241
|
+
digest: bool) -> tuple[list[Alert], Optional[str]]:
|
|
242
|
+
"""The alerts of a run, or why it failed, with the run saved in the state file. A
|
|
243
|
+
state file that can't be read is left as it is."""
|
|
244
|
+
try:
|
|
245
|
+
state = load_state(state_path)
|
|
246
|
+
except (OSError, ValueError) as exc:
|
|
247
|
+
traceback.print_exc()
|
|
248
|
+
return [], f"can't read the state file {state_path}: {exc}"
|
|
249
|
+
updated = copy.deepcopy(state)
|
|
250
|
+
try:
|
|
251
|
+
alerts = _alerts(source, state_path, updated, cfg, today, started, digest)
|
|
252
|
+
record_run(updated, started, None)
|
|
253
|
+
save_state(state_path, updated)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
traceback.print_exc()
|
|
256
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
257
|
+
record_run(state, started, error)
|
|
258
|
+
try:
|
|
259
|
+
save_state(state_path, state)
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
return [], error
|
|
263
|
+
return alerts, None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
FAILURE_NOTICE_HOURS = 20
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run_check(source: Path, state_path: Path, cfg: Optional[DetectorConfig] = None,
|
|
270
|
+
notify_user: bool = False, today: Optional[date] = None,
|
|
271
|
+
exec_command: Optional[str] = None, now: Optional[datetime] = None,
|
|
272
|
+
digest: bool = True) -> int:
|
|
273
|
+
"""Run the daily check and print a line per alert; with notify_user, also show a
|
|
274
|
+
notification for each; with exec_command, also run it for each (see
|
|
275
|
+
notify.run_exec). The state file is read once and written once, with how the
|
|
276
|
+
run went, so `ccdrift status` can tell a broken check from a quiet week, and
|
|
277
|
+
stays locked in between (see state.state_lock). A state file that can't be read
|
|
278
|
+
is left as it is. Without `digest`, no weekly summary."""
|
|
279
|
+
started = now or datetime.now().astimezone()
|
|
280
|
+
stamp = started.strftime("%Y-%m-%d %H:%M")
|
|
281
|
+
notice = state_path.with_name(state_path.name + ".last-failure-notice")
|
|
282
|
+
|
|
283
|
+
def alert(kind: str, title: str, message: str, send: bool = True) -> None:
|
|
284
|
+
print(f"[check {stamp}] {title}: {message}")
|
|
285
|
+
if not send:
|
|
286
|
+
return
|
|
287
|
+
# The state already records the alerts as sent, so nothing may stop the rest.
|
|
288
|
+
if notify_user:
|
|
289
|
+
try:
|
|
290
|
+
notify(title, message)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
print(f'[check {stamp}] notification failed for "{title}": {type(exc).__name__}: {exc}')
|
|
293
|
+
if exec_command:
|
|
294
|
+
try:
|
|
295
|
+
failure = run_exec(exec_command, kind, title, message)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
failure = f"{type(exc).__name__}: {exc}"
|
|
298
|
+
if failure:
|
|
299
|
+
print(f'[check {stamp}] --exec failed for "{title}": {failure}')
|
|
300
|
+
|
|
301
|
+
def failure_notice_due() -> bool:
|
|
302
|
+
"""A failure notifies and runs --exec at most once per FAILURE_NOTICE_HOURS
|
|
303
|
+
until a run succeeds. A file next to the state file notes when it last did, so
|
|
304
|
+
a state file that can't be read or saved doesn't notify on every run; when that
|
|
305
|
+
file can't be read or written either, the failure notifies."""
|
|
306
|
+
try:
|
|
307
|
+
if started - datetime.fromisoformat(notice.read_text().strip()) < timedelta(hours=FAILURE_NOTICE_HOURS):
|
|
308
|
+
return False
|
|
309
|
+
except (OSError, ValueError, TypeError):
|
|
310
|
+
pass
|
|
311
|
+
try:
|
|
312
|
+
notice.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
notice.write_text(started.isoformat(timespec="seconds") + "\n")
|
|
314
|
+
except OSError:
|
|
315
|
+
pass
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
with state_lock(state_path):
|
|
320
|
+
alerts, failure = _run_on_state(source, state_path, cfg or DetectorConfig(),
|
|
321
|
+
today or datetime.now(timezone.utc).date(), started, digest)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
traceback.print_exc()
|
|
324
|
+
alerts, failure = [], f"can't read the state file {state_path}: {exc}"
|
|
325
|
+
# Alerts go out once the lock is released: notifications and --exec take their time.
|
|
326
|
+
if failure is not None:
|
|
327
|
+
alert("failed", "ccdrift check failed", failure, failure_notice_due())
|
|
328
|
+
return 1
|
|
329
|
+
try:
|
|
330
|
+
notice.unlink(missing_ok=True)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
for kind, title, message, details in alerts:
|
|
334
|
+
alert(kind, title, message, send=kind not in LOG_ONLY)
|
|
335
|
+
for detail in details:
|
|
336
|
+
print(f" {detail}")
|
|
337
|
+
if not alerts:
|
|
338
|
+
print(f"[check {stamp}] no alerts")
|
|
339
|
+
return 0
|