fix(agents): a claude purge keeps ownership evidence until last (Issue #31, review)
A session file is what ties a pid's key file to the session, and a job's state.json what ties the job dir: removing them first left the rest unattributable after a partial failure (residual: none, and a retry could not find it). Keys now go before their session file, which stays while any key does; a job dir is emptied with state.json last and kept if anything stays. residual lists a session file's key files (masked). A startedAt of 0 or less cannot tell a reused pid and counts as live; the sessions/ listdir no longer raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a4f651bdf
commit
f542596975
1 file changed
+54
-11
@@ -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/<pid>.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,8 +297,16 @@ 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):
|
||||
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))
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user