cc-session-control 0.4.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.
@@ -0,0 +1,402 @@
1
+ """Cleanup strategies for Claude Code's on-disk state (D6/R7).
2
+
3
+ Two strategies, both preview-first (a `list_*`/`*_stats` read split from the
4
+ matching `remove_*` write, so the view can preview then confirm):
5
+
6
+ - **Strategy A — key-typed orphan sweep.** Key semantics are PER DIRECTORY,
7
+ never a blanket `uuid == sessionId` rule:
8
+ * sid-keyed dirs (`session-env`, `file-history`, `tasks`, `uploads`):
9
+ orphan = an entry whose name (a sessionId) is not in the PROTECTED sid set.
10
+ That set (H1 safety, `known_sids`) is the union of transcript sids,
11
+ registry `sessions/<pid>.json` + `jobs/<short>/state.json` sids, live sids
12
+ (`claude agents --json`, proc-alive, host-alive jobs), and the current
13
+ session — so the sweep never deletes artifacts of a registry-known, live,
14
+ or current session/agent even when its transcript was dropped.
15
+ * pid-keyed dir (`sessions/<pid>.json`): remove only zombies
16
+ (`not pid_alive`), excluding the current session's pid AND any live pid —
17
+ for a resumed multi-pid sid we drop the dead pid files but keep the alive
18
+ one.
19
+ * `debug/`: its uuids are debug-run ids, NOT sessionIds — never treated as
20
+ sid-orphans (it is simply not in the sid-keyed set).
21
+ - **Strategy B — age sweep** for non-session-keyed global dirs
22
+ (`shell-snapshots`, `telemetry`, `plans`, `backups`, `paste-cache`): remove
23
+ entries with an mtime older than `cfg.cleanup_age_days`.
24
+
25
+ `jobs/` is deliberately NOT auto-orphan-swept (only Phase 6's explicit per-job
26
+ remove touches it). All paths come from `cfg.*` props — no inline path joins.
27
+
28
+ R10 safety: when the "current" session can't be determined (no `/proc`),
29
+ destructive ops here refuse (return empty/no-op) rather than fail open — without
30
+ `/proc` every pid looks dead, so a zombie sweep would delete the live/current
31
+ session's files. Strategy B is mtime-only and session-agnostic, so it is not
32
+ gated on `/proc`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import shutil
39
+ import time
40
+ from dataclasses import replace
41
+
42
+ from ..config import cfg
43
+ from ..models import AgentJob, Session, SessionProc
44
+ from . import liveness, proc, registry
45
+
46
+ # Dirs keyed by full sessionId — orphan = name not in the known sid set.
47
+ _SID_DIRS = ("session_env", "file_history", "tasks", "uploads")
48
+ # Dirs swept purely by mtime (not session-keyed).
49
+ _AGE_DIRS = ("shell_snapshots", "telemetry", "plans", "backups", "paste_cache")
50
+
51
+ _SECONDS_PER_DAY = 86400
52
+
53
+
54
+ def _sid_dir_paths() -> list[tuple[str, str]]:
55
+ """(label, path) for each sid-keyed directory, via cfg props only."""
56
+ return [(name.replace("_", "-"), str(getattr(cfg, f"{name}_dir")))
57
+ for name in _SID_DIRS]
58
+
59
+
60
+ def _age_dir_paths() -> list[tuple[str, str]]:
61
+ """(label, path) for each age-swept directory, via cfg props only."""
62
+ return [(name.replace("_", "-"), str(getattr(cfg, f"{name}_dir")))
63
+ for name in _AGE_DIRS]
64
+
65
+
66
+ def _sid_keyed_paths(sid: str) -> list[str]:
67
+ """The sid-keyed artifact dirs (session-env/file-history/tasks/uploads)."""
68
+ return [os.path.join(p, sid) for _, p in _sid_dir_paths()]
69
+
70
+
71
+ def _jobs_path(sid: str) -> str:
72
+ """The 8-char-prefixed `jobs/<short>` dir for a session id."""
73
+ return os.path.join(str(cfg.jobs_dir), sid[:8])
74
+
75
+
76
+ def _session_artifact_paths(sid: str) -> list[str]:
77
+ """All on-disk artifact paths owned by one session id (cfg-derived).
78
+
79
+ Covers the sid-keyed dirs plus the 8-char-prefixed `jobs/<short>` dir for
80
+ this session. Used by `agent_ops.remove_job` (which has already alive-gated
81
+ the job). `remove_session` does NOT use this — it guards the `jobs/<short>`
82
+ path separately so a LIVE agent worker's jobs dir is never deleted (M3).
83
+ """
84
+ return _sid_keyed_paths(sid) + [_jobs_path(sid)]
85
+
86
+
87
+ def _remove_path(path: str) -> bool:
88
+ """Remove a file or directory; True iff something was removed."""
89
+ if os.path.isdir(path):
90
+ shutil.rmtree(path, ignore_errors=True)
91
+ return True
92
+ if os.path.isfile(path):
93
+ try:
94
+ os.remove(path)
95
+ return True
96
+ except OSError:
97
+ return False
98
+ return False
99
+
100
+
101
+ # --- Strategy A: sid-keyed orphan dirs (H1 protected-sid set) --------------
102
+
103
+ def _live_session_procs(max_age: float = 5.0) -> list[SessionProc]:
104
+ """Registry session files with `/proc` liveness injected (swallow-error)."""
105
+ try:
106
+ return [
107
+ replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
108
+ for sp in registry.read_session_procs(max_age=max_age)
109
+ ]
110
+ except Exception:
111
+ return []
112
+
113
+
114
+ def known_sids(
115
+ sessions: list[Session],
116
+ session_procs: list[SessionProc],
117
+ agent_jobs: list[AgentJob],
118
+ agents_map: dict[str, int | None],
119
+ cur: set[int],
120
+ ) -> set[str]:
121
+ """Sids whose sid-keyed artifacts must NOT be swept (H1 safety) — PURE.
122
+
123
+ A sid-keyed dir is an orphan only when its sid is in NONE of these protected
124
+ sets, so the sweep never deletes artifacts of a registry-known, live, or
125
+ current session/agent (the old `{s.sid for s in sessions}` dropped no-cwd
126
+ bg/bridge stubs and ignored the registry + liveness entirely):
127
+ - transcript scan (`sessions`), incl. the current one
128
+ - registry `sessions/<pid>.json` sids (`session_procs`)
129
+ - registry `jobs/<short>/state.json` sids + resume sids (`agent_jobs`)
130
+ - live per `claude agents --json` (`agents_map`)
131
+ - proc-alive in `session_procs` (defeats pid reuse)
132
+ - host-alive agent jobs
133
+ - the current (csctl-launching) session (`s.current` / pid in `cur`)
134
+ Inputs injected so it stays unit-testable.
135
+ """
136
+ known: set[str] = {s.sid for s in sessions}
137
+ known |= {s.sid for s in sessions if s.current}
138
+ known |= {sp.sid for sp in session_procs}
139
+ known |= {sp.sid for sp in session_procs if sp.proc_alive}
140
+ known |= {sp.sid for sp in session_procs if sp.pid in cur}
141
+ for j in agent_jobs:
142
+ if j.sid:
143
+ known.add(j.sid)
144
+ if j.resume_sid:
145
+ known.add(j.resume_sid)
146
+ if j.host_alive and j.sid:
147
+ known.add(j.sid)
148
+ known |= {sid for sid in agents_map if sid}
149
+ return known
150
+
151
+
152
+ def _gather_known(
153
+ sessions: list[Session],
154
+ session_procs: list[SessionProc] | None,
155
+ agent_jobs: list[AgentJob] | None,
156
+ agents_map: dict[str, int | None] | None,
157
+ cur: set[int] | None,
158
+ ) -> set[str]:
159
+ """Resolve the protected-sid set, self-fetching any omitted source.
160
+
161
+ Snapshot/view callers inject the shared world data (R11); CLI / no-snapshot
162
+ callers pass nothing and we read the (TTL-cached) registry + `alive_map`. Each
163
+ self-read swallows its own errors → safe empties.
164
+ """
165
+ if session_procs is None:
166
+ session_procs = _live_session_procs()
167
+ if agent_jobs is None:
168
+ try:
169
+ agent_jobs = registry.read_agent_jobs()
170
+ except Exception:
171
+ agent_jobs = []
172
+ if agents_map is None:
173
+ try:
174
+ agents_map = liveness.alive_map()
175
+ except Exception:
176
+ agents_map = {}
177
+ if cur is None:
178
+ cur = proc.ancestor_pids()
179
+ return known_sids(sessions, session_procs, agent_jobs, agents_map, cur)
180
+
181
+
182
+ def list_orphan_dirs(
183
+ sessions: list[Session],
184
+ *,
185
+ session_procs: list[SessionProc] | None = None,
186
+ agent_jobs: list[AgentJob] | None = None,
187
+ agents_map: dict[str, int | None] | None = None,
188
+ cur: set[int] | None = None,
189
+ ) -> list[str]:
190
+ """Orphan sid-keyed artifact entries (`<dir>/<sid>`), preview list.
191
+
192
+ An entry is an orphan only when its sid is NOT in the protected set (H1).
193
+ Refuses (returns []) when current can't be determined (R10).
194
+ """
195
+ if not proc.current_determinable():
196
+ return []
197
+ known = _gather_known(sessions, session_procs, agent_jobs, agents_map, cur)
198
+ orphans: list[str] = []
199
+ for label, path in _sid_dir_paths():
200
+ if not os.path.isdir(path):
201
+ continue
202
+ for name in os.listdir(path):
203
+ if name not in known:
204
+ orphans.append(os.path.join(label, name))
205
+ return sorted(set(orphans))
206
+
207
+
208
+ def remove_orphan_dirs(
209
+ sessions: list[Session],
210
+ *,
211
+ session_procs: list[SessionProc] | None = None,
212
+ agent_jobs: list[AgentJob] | None = None,
213
+ agents_map: dict[str, int | None] | None = None,
214
+ cur: set[int] | None = None,
215
+ ) -> int:
216
+ """Delete orphan sid-keyed artifact entries. Refuses without `/proc`.
217
+
218
+ Protects registry-known / live / current sids (H1) — see `known_sids`.
219
+ """
220
+ if not proc.current_determinable():
221
+ return 0
222
+ known = _gather_known(sessions, session_procs, agent_jobs, agents_map, cur)
223
+ count = 0
224
+ for _label, path in _sid_dir_paths():
225
+ if not os.path.isdir(path):
226
+ continue
227
+ for name in os.listdir(path):
228
+ if name in known:
229
+ continue
230
+ if _remove_path(os.path.join(path, name)):
231
+ count += 1
232
+ return count
233
+
234
+
235
+ # --- Strategy A: pid-keyed zombie session files ----------------------------
236
+
237
+ def select_zombie_pids(session_procs: list[SessionProc], cur: set[int]) -> list[int]:
238
+ """Removable `sessions/<pid>.json` pids — PURE (no IO), for unit tests.
239
+
240
+ A pid file is removable iff its proc is dead (`not proc_alive`) and the pid
241
+ is neither the current session's nor a live one. For a resumed multi-pid sid
242
+ this returns only the dead pid(s); the live pid's file is kept because its
243
+ injected `proc_alive` is True. Inputs are injected (proc liveness already on
244
+ each `SessionProc`, `cur` = ancestor-pid set).
245
+ """
246
+ out: list[int] = []
247
+ for sp in session_procs:
248
+ if sp.pid in cur: # current session's pid file — protected
249
+ continue
250
+ if sp.proc_alive: # live runtime — keep (multi-pid: keep alive pid)
251
+ continue
252
+ out.append(sp.pid)
253
+ return sorted(set(out))
254
+
255
+
256
+ def remove_zombie_session_files(session_procs: list[SessionProc], cur: set[int]) -> int:
257
+ """Delete zombie `sessions/<pid>.json` files. Refuses without `/proc`."""
258
+ if not proc.current_determinable():
259
+ return 0
260
+ count = 0
261
+ for pid in select_zombie_pids(session_procs, cur):
262
+ if _remove_path(os.path.join(str(cfg.sessions_dir), f"{pid}.json")):
263
+ count += 1
264
+ return count
265
+
266
+
267
+ # --- Strategy B: age sweep -------------------------------------------------
268
+
269
+ def _age_cutoff(now: float) -> float:
270
+ return now - cfg.cleanup_age_days * _SECONDS_PER_DAY
271
+
272
+
273
+ def list_aged_entries(now: float | None = None) -> list[str]:
274
+ """Age-swept entries (`<dir>/<name>`) older than `cfg.cleanup_age_days`."""
275
+ cutoff = _age_cutoff(time.time() if now is None else now)
276
+ out: list[str] = []
277
+ for label, path in _age_dir_paths():
278
+ if not os.path.isdir(path):
279
+ continue
280
+ for name in os.listdir(path):
281
+ try:
282
+ if os.stat(os.path.join(path, name)).st_mtime < cutoff:
283
+ out.append(os.path.join(label, name))
284
+ except OSError:
285
+ pass
286
+ return sorted(out)
287
+
288
+
289
+ def remove_aged_entries(now: float | None = None) -> int:
290
+ """Delete age-swept entries older than `cfg.cleanup_age_days`."""
291
+ cutoff = _age_cutoff(time.time() if now is None else now)
292
+ count = 0
293
+ for _label, path in _age_dir_paths():
294
+ if not os.path.isdir(path):
295
+ continue
296
+ for name in os.listdir(path):
297
+ full = os.path.join(path, name)
298
+ try:
299
+ if os.stat(full).st_mtime >= cutoff:
300
+ continue
301
+ except OSError:
302
+ continue
303
+ if _remove_path(full):
304
+ count += 1
305
+ return count
306
+
307
+
308
+ # --- Session prune + full delete -------------------------------------------
309
+
310
+ def prune_sessions(sessions: list[Session], max_prompts: int = 0) -> list[Session]:
311
+ """Prunable sessions: not alive, not current, <= max_prompts, not recent.
312
+
313
+ Refuses (returns []) when current can't be determined (R10): without `/proc`
314
+ `current` is unreliable, so we must not propose deleting anything.
315
+ """
316
+ if not proc.current_determinable():
317
+ return []
318
+ alive_sids = {s.sid for s in sessions if s.alive}
319
+ now = time.time()
320
+ return [
321
+ s for s in sessions
322
+ if s.prompts <= max_prompts
323
+ and s.sid not in alive_sids
324
+ and not s.current
325
+ and (now - s.mtime) > 600
326
+ ]
327
+
328
+
329
+ def remove_session(s: Session) -> bool:
330
+ """Delete one session: its `.jsonl`, companion dir, and sid artifacts.
331
+
332
+ Returns True iff something was removed; False when it refused (R10) or there
333
+ was nothing to remove (L4 — the view reports honestly).
334
+
335
+ Refuses (no-op, False) when current can't be determined (R10) — without
336
+ `/proc` we cannot prove `s` is not the launching session.
337
+
338
+ M3: the `jobs/<short>` dir is removed ONLY when the sid has no LIVE host pid,
339
+ so a live background worker's jobs dir is protected exactly like
340
+ `agent_ops.remove_job` protects it (do not bypass the jobs/ guard).
341
+ """
342
+ if not proc.current_determinable():
343
+ return False
344
+ removed = False
345
+ try:
346
+ os.remove(s.file)
347
+ removed = True
348
+ except OSError:
349
+ pass
350
+ if _remove_path(s.file[:-6]): # companion dir (drop the .jsonl suffix)
351
+ removed = True
352
+ for p in _sid_keyed_paths(s.sid):
353
+ if _remove_path(p):
354
+ removed = True
355
+ # M3: never delete a LIVE agent worker's jobs/<short> dir.
356
+ _, host_alive = registry.host_pid_for_sid(s.sid, _live_session_procs())
357
+ if not host_alive and _remove_path(_jobs_path(s.sid)):
358
+ removed = True
359
+ return removed
360
+
361
+
362
+ # --- Classified counts -----------------------------------------------------
363
+
364
+ def cleanup_stats(sessions: list[Session]) -> dict[str, int]:
365
+ """Summary counts for the Sessions cleanup submenu (view-facing contract).
366
+
367
+ Keeps the established 4-key shape (`total/empty/short/orphans`) that the
368
+ view reads; `orphans` is the sid-keyed orphan count (Strategy A).
369
+ """
370
+ return {
371
+ "total": len(sessions),
372
+ "empty": sum(1 for s in sessions if s.prompts == 0),
373
+ "short": sum(1 for s in sessions if 0 < s.prompts <= 2),
374
+ "orphans": len(list_orphan_dirs(sessions)),
375
+ }
376
+
377
+
378
+ def cleanup_classified(
379
+ sessions: list[Session],
380
+ session_procs: list[SessionProc],
381
+ cur: set[int],
382
+ agent_jobs: list[AgentJob] | None = None,
383
+ agents_map: dict[str, int | None] | None = None,
384
+ now: float | None = None,
385
+ ) -> dict[str, int]:
386
+ """Per-category cleanup counts (D6). Deps injected so it stays unit-testable.
387
+
388
+ Breaks the cleanup surface into its categories — empty/short sessions,
389
+ sid-keyed orphan dirs, pid-keyed zombie session files, and age-swept global
390
+ entries — for the (Phase 7) workbench view to surface. The shared world data
391
+ (`session_procs`/`agent_jobs`/`agents_map`/`cur`) feeds the H1 protected-sid
392
+ set so the orphan count never includes live/registry-known sids.
393
+ """
394
+ return {
395
+ "empty": sum(1 for s in sessions if s.prompts == 0),
396
+ "short": sum(1 for s in sessions if 0 < s.prompts <= 2),
397
+ "orphan_dirs": len(list_orphan_dirs(
398
+ sessions, session_procs=session_procs, agent_jobs=agent_jobs,
399
+ agents_map=agents_map, cur=cur)),
400
+ "zombie_procs": len(select_zombie_pids(session_procs, cur)),
401
+ "aged_entries": len(list_aged_entries(now)),
402
+ }