diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index 6243d37..a81d305 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -70,6 +70,8 @@ def _session_file_live(data: dict, pid) -> bool: started = float(data["startedAt"]) / 1000.0 except Exception: return True + if started <= 0: + return True # startedAt is written 1.7-3.2 s after the process starts; 2 s covers # lstart's one-second resolution. return lstart <= started + 2 @@ -85,6 +87,41 @@ def _remove(path: str) -> None: pass +def _key_files(session_file: str) -> list: + """The key files of the pid a sessions/.json is named after, + matched by name only; their content is never read.""" + d, name = os.path.split(session_file) + stem = name[:-len(".json")] + if not stem.isdigit(): + return [] + try: + names = sorted(os.listdir(d)) + except OSError: + return [] + return [os.path.join(d, n) for n in names if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n)] + + +def _remove_job(job: str) -> None: + """state.json goes last: it is the only proof that the dir is this + session's, so a dir that could not be emptied keeps it.""" + if os.path.islink(job): + _remove(job) + return + try: + names = os.listdir(job) + except OSError: + return + for n in names: + if n != "state.json": + _remove(os.path.join(job, n)) + try: + left = os.listdir(job) + except OSError: + return + if left in ([], ["state.json"]): + _remove(job) + + def _label(path: str) -> str: """How a path is printed: a session key file is named by pid only, its hex part is not shown.""" @@ -235,15 +272,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter): if job: paths.append(job) for path, pid, data in self._session_files(uuid, ctx): - if _session_file_live(data, pid): - continue - paths.append(path) - stem = os.path.basename(path)[:-len(".json")] - sessions = os.path.dirname(path) - if stem.isdigit(): - # The key file is matched by name only; its content is never read. - paths.extend(os.path.join(sessions, n) for n in sorted(os.listdir(sessions)) - if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n)) + if not _session_file_live(data, pid): + # Keys before the session file: the file is what ties them to + # this session, so it must outlive them. + paths += _key_files(path) + [path] for root in _tmp_roots(): try: keys = sorted(os.listdir(root)) @@ -265,9 +297,17 @@ class ClaudeAgentAdapter(BaseAgentAdapter): # Best effort: never raises (stop runs this inside its registry # transaction). What could not be removed is not listed here and # shows up in residual_artifacts instead. + # Ownership evidence (a job's state.json, a session file) is removed + # only after what it vouches for, so a retry can still find the rest. purged = [] + job = self._owned_job(uuid, ctx) for path in self._owned_paths(uuid, ctx): - _remove(path) + if path == job: + _remove_job(path) + elif path.endswith(".json") and any(os.path.lexists(k) for k in _key_files(path)): + continue + else: + _remove(path) if not os.path.lexists(path) and _label(path) not in purged: purged.append(_label(path)) return purged @@ -276,7 +316,8 @@ class ClaudeAgentAdapter(BaseAgentAdapter): """What still carries this session: names holding the uuid under the config root, in projects (two levels: key, then entry) and in the tmp roots (two levels), plus the content-matched jobs dir and session - files. projects is not walked deeper; it can be very large.""" + files with their (masked) key files. projects is not walked deeper; + it can be very large.""" if not _plausible_id(uuid): return [] cfg = _config_dir(ctx) @@ -299,7 +340,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter): job = self._owned_job(uuid, ctx) if job: found.add(job) - found.update(path for path, _, _ in self._session_files(uuid, ctx)) + for path, _, _ in self._session_files(uuid, ctx): + found.add(path) + found.update(_label(k) for k in _key_files(path)) for root in _tmp_roots(): try: tkeys = os.listdir(root)