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
+55
-12
@@ -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,9 +297,17 @@ 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):
|
||||||
_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:
|
if not os.path.lexists(path) and _label(path) not in purged:
|
||||||
purged.append(_label(path))
|
purged.append(_label(path))
|
||||||
return purged
|
return purged
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user