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:
GodopuandClaude Opus 5.5 committed 2026-10-15 16:00:40 +00:00
1 parent 8a4f651bdf
commit f542596975
1 file changed
+54 -11
+54 -11
View File
@@ -70,6 +70,8 @@ def _session_file_live(data: dict, pid) -> bool:
started = float(data["startedAt"]) / 1000.0 started = float(data["startedAt"]) / 1000.0
except Exception: except Exception:
return True return True
if started <= 0:
return True
# startedAt is written 1.7-3.2 s after the process starts; 2 s covers # startedAt is written 1.7-3.2 s after the process starts; 2 s covers
# lstart's one-second resolution. # lstart's one-second resolution.
return lstart <= started + 2 return lstart <= started + 2
@@ -85,6 +87,41 @@ def _remove(path: str) -> None:
pass 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: def _label(path: str) -> str:
"""How a path is printed: a session key file is named by pid only, its """How a path is printed: a session key file is named by pid only, its
hex part is not shown.""" hex part is not shown."""
@@ -235,15 +272,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
if job: if job:
paths.append(job) paths.append(job)
for path, pid, data in self._session_files(uuid, ctx): for path, pid, data in self._session_files(uuid, ctx):
if _session_file_live(data, pid): if not _session_file_live(data, pid):
continue # Keys before the session file: the file is what ties them to
paths.append(path) # this session, so it must outlive them.
stem = os.path.basename(path)[:-len(".json")] paths += _key_files(path) + [path]
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))
for root in _tmp_roots(): for root in _tmp_roots():
try: try:
keys = sorted(os.listdir(root)) keys = sorted(os.listdir(root))
@@ -265,8 +297,16 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
# Best effort: never raises (stop runs this inside its registry # Best effort: never raises (stop runs this inside its registry
# transaction). What could not be removed is not listed here and # transaction). What could not be removed is not listed here and
# shows up in residual_artifacts instead. # 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 = [] purged = []
job = self._owned_job(uuid, ctx)
for path in self._owned_paths(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) _remove(path)
if not os.path.lexists(path) and _label(path) not in purged: if not os.path.lexists(path) and _label(path) not in purged:
purged.append(_label(path)) purged.append(_label(path))
@@ -276,7 +316,8 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
"""What still carries this session: names holding the uuid under the """What still carries this session: names holding the uuid under the
config root, in projects (two levels: key, then entry) and in the tmp config root, in projects (two levels: key, then entry) and in the tmp
roots (two levels), plus the content-matched jobs dir and session 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): if not _plausible_id(uuid):
return [] return []
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
@@ -299,7 +340,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
job = self._owned_job(uuid, ctx) job = self._owned_job(uuid, ctx)
if job: if job:
found.add(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(): for root in _tmp_roots():
try: try:
tkeys = os.listdir(root) tkeys = os.listdir(root)