Compare commits

...
11 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 2a8d73825b test(a4): the composite purge names its claude session by a real uuid (Issue #31, review)
claude now takes only a canonical uuid (81b6c2e), so the fixture id
'uuid-c' purged nothing. The three claude spellings in
test_purge_artifacts_composite become one uuid literal; the assertions
and the other adapters' parts are unchanged. Approved by the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:43:23 +00:00
GodopuandClaude Opus 5.5 81b6c2ea6b fix(agents): only a canonical uuid reaches any claude path (Issue #31, review)
The user asked for no path at all from an id that is not a uuid. bd09c6a
still took `<id>.jsonl` for one (the pre-#31 reach), which has_artifacts and
the residual counted while in-use always said free: `purge claude notes`
deleted notes.jsonl. Now purge, has_artifacts and residual return nothing
for such an id, as in-use already did.

This makes test_a4_adapter_contract.py::test_purge_artifacts_composite fail:
it purges the claude id 'uuid-c' and expects uuid-c.jsonl gone. Editing an
existing test needs the user's approval; the one-line data change
(a canonical uuid in place of 'uuid-c') is kept outside the branch, in job
90efa4ce's report dir. Reverting this commit restores the jsonl-only reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:09:32 +00:00
GodopuandClaude Opus 5.5 dd292c12cb fix(agents): a claude leftover behind a link still selects claude for its residual (Issue #31, review)
has_artifacts counted only the paths a purge may remove. When all that was
left lay behind a linked dir, `purge claude <uuid>` (or auto) found no
adapter and exited 5 with an empty residual, hiding the path the purge
had refused to follow. has_artifacts now counts every candidate; _inside
still decides what is removed, so the CLI exits 0, removes nothing and
lists the linked path as residual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:07:49 +00:00
GodopuandClaude Opus 5.5 c0248b88be fix(agents): any spelling of a claude uuid is checked as claude's lowercase id (Issue #31, review)
_is_uuid took an uppercase or mixed-case uuid, and the paths built from it
are the live lowercase session's own files on macOS's default
case-insensitive APFS. The in-use checks compare exactly, though: session
files and command lines carry the lowercase id, so `purge claude <UPPER>`
found no holder and could remove a live session's files. _canonical now
lowercases a canonical uuid, and the paths, the session-file and job
matches, the in-use check and the residual all use it; a command line is
searched for both spellings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:06:37 +00:00
GodopuandClaude Opus 5.5 60779ba1f8 fix(agents): a startedAt too large for a float counts as live (Issue #31, review)
json.load reads a huge integer as an int, and math.isfinite raised
OverflowError on it outside any try: conversation_in_use and purge_artifacts
(which must never raise) threw, where 6f4f40a had taken it as live. The
conversion to float now comes first, and an overflow means live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:05:13 +00:00
GodopuandClaude Opus 5.5 e156179822 fix(stop): the exit-4 refusal names where the holder may be (Issue #31, Mac review)
The refusal pointed at ~/.claude/sessions/ for every agent, whatever
CLAUDE_CONFIG_DIR said, and never mentioned a non-agent process naming the
uuid on its command line. The in-use CLI now prints, on stderr while the
conversation is in use, the adapter's in_use_hints: for every adapter
`ps -axww | grep <uuid>`, and for claude its real sessions/ dir first. stop
prints them under its refusal, and says "records preserved" instead of
"no state was modified": the gate runs after the kill chain, so the session
itself may already be down. SKILL.md's exit 4 row says the same; it is not
a code block, so no inventory hash moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:25:10 +00:00
GodopuandClaude Opus 5.5 af8ed397f6 fix(agents): compare a claude process's lstart in UTC (Issue #31, Mac review)
time.mktime read ps's local lstart, which is ambiguous in the hour a DST
change repeats: the start could come out an hour off and a live holder be
taken for a reused pid (or the reverse). ps now runs with TZ=UTC0, which BSD
ps and procps both honour, and calendar.timegm reads it. No /proc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:23:17 +00:00
GodopuandClaude Opus 5.5 f943b0db30 fix(agents): a session file whose startedAt is no finite time is live (Issue #31, Mac review)
float("NaN") and a JSON NaN passed the old check, and every comparison with
NaN is false, so a live holder looked like a reused pid: the in-use gate let
the purge through and the session file was taken as stale. startedAt must
now be an int or float (not bool), finite and positive; anything else cannot
be told and counts as live. A dead pid is still stale whatever it says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:21:50 +00:00
GodopuandClaude Opus 5.5 6e0361cf43 fix(agents): a claude purge never follows a linked dir below its root (Issue #31, Mac review)
A tmp-root entry <key> linking outside made `<key>/<uuid>` look like a plain
directory, and rmtree removed what lay behind the link; projects/<key>,
file-history/ and the other config dirs had no such check either. Each
candidate now keeps the root it was found under (config, projects or tmp),
and is purged only when every component between that root and the leaf is a
real directory and its parent resolves inside the resolved root. A root may
itself be a link (/tmp -> /private/tmp), a leaf link is still removed as the
link alone, and a candidate behind a link is reported as residual instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:20:55 +00:00
GodopuandClaude Opus 5.5 bd09c6a133 fix(agents): a claude purge takes directories only for a canonical uuid (Issue #31, Mac review)
projects/<key>/memory/ (Claude Code's auto memory) sits beside the session
dirs, and `purge claude memory` removed it whole. Only an 8-4-4-4-12 hex id
now reaches the session's directories, session files, jobs and tmp dirs. Any
other id keeps the pre-#31 reach, the `<id>.jsonl` file alone (test_a4 still
purges "uuid-c"); its residual is that file and nothing holds it in use.
The shared _plausible_id is unchanged for the other adapters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:19:02 +00:00
GodopuandClaude Opus 5.5 26fb1c4130 test(issue31): keep every claude tmp root candidate inside the test
The adapter's fixed /tmp fallback is now a module constant. The new tests
point it, TMPDIR, TMP and TEMP at the test's own dirs, in the test process
and (through a sitecustomize on PYTHONPATH) in every python a test starts,
so no test lists the real /tmp/claude-<uid> any more (Mac review, PR #59).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:16:36 +00:00
7 changed files with 381 additions and 49 deletions

No files matched your search

+6 -1
View File
@@ -155,7 +155,8 @@ def main():
# in-use <agent> <uuid> [workspace] [--wait <sec>] (Issue #31) # in-use <agent> <uuid> [workspace] [--wait <sec>] (Issue #31)
# The purge CLI's own in-use verdict, for stop_session.sh to ask # The purge CLI's own in-use verdict, for stop_session.sh to ask
# before it purges. Re-checks every 0.25 s for up to --wait seconds. # before it purges. Re-checks every 0.25 s for up to --wait seconds.
# Prints {"agent", "in_use"}. Exit 0 free; 4 still in use; 1 bad args. # Prints {"agent", "in_use"}, and while in use where to look on
# stderr. Exit 0 free; 4 still in use; 1 bad args.
args = sys.argv[2:] args = sys.argv[2:]
wait = 0.0 wait = 0.0
if '--wait' in args: if '--wait' in args:
@@ -181,6 +182,10 @@ def main():
time.sleep(0.25) time.sleep(0.25)
in_use = adapter.conversation_in_use(uuid, ctx) in_use = adapter.conversation_in_use(uuid, ctx)
print(json.dumps({"agent": agent_name, "in_use": in_use})) print(json.dumps({"agent": agent_name, "in_use": in_use}))
if in_use:
print(" What may hold it:", file=sys.stderr)
for hint in adapter.in_use_hints(uuid, ctx):
print(f" {hint}", file=sys.stderr)
sys.exit(4 if in_use else 0) sys.exit(4 if in_use else 0)
else: else:
+99 -36
View File
@@ -1,9 +1,23 @@
import os, re, json, glob, time, shutil, subprocess import os, re, json, glob, math, stat, time, shutil, calendar, subprocess
from typing import Optional, Any from typing import Optional, Any
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names, _plausible_id from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, conversation_in_use, scan_names
from lib_py.verify_session import workspace_key from lib_py.verify_session import workspace_key
_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
def _canonical(uuid) -> Optional[str]:
"""The id claude itself writes, in lowercase, for any spelling of a
canonical uuid; None for anything else. Any other name in a session's
place is not one: projects/<key>/memory/ (auto memory) sits beside the
session dirs. Another spelling must go through the same checks as
claude's own: on macOS's default case-insensitive APFS it names the
same files, while the ids in session files and on command lines are
compared exactly."""
return uuid.lower() if isinstance(uuid, str) and _UUID.fullmatch(uuid) else None
def _config_dir(ctx: DiscoveryContext) -> str: def _config_dir(ctx: DiscoveryContext) -> str:
"""claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/, """claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/,
file-history/, session-env/, jobs/, projects/).""" file-history/, session-env/, jobs/, projects/)."""
@@ -22,6 +36,10 @@ def _projects_dirs(ctx: DiscoveryContext) -> list:
return dirs return dirs
# Node's os.tmpdir() when no TMPDIR/TMP/TEMP is set; tests point it elsewhere.
_SYSTEM_TMP = "/tmp"
def _tmp_roots() -> list: def _tmp_roots() -> list:
"""The per-uid temp roots claude may have used: ${CLAUDE_CODE_TMPDIR:- """The per-uid temp roots claude may have used: ${CLAUDE_CODE_TMPDIR:-
os.tmpdir()}/claude-<uid>. A sandboxed tool shell sets CLAUDE_CODE_TMPDIR os.tmpdir()}/claude-<uid>. A sandboxed tool shell sets CLAUDE_CODE_TMPDIR
@@ -36,9 +54,9 @@ def _tmp_roots() -> list:
cands.append(override) cands.append(override)
# Node's os.tmpdir() on POSIX, not tempfile.gettempdir() (which probes # Node's os.tmpdir() on POSIX, not tempfile.gettempdir() (which probes
# writability and falls back to the cwd). # writability and falls back to the cwd).
tmp = os.environ.get("TMPDIR") or os.environ.get("TMP") or os.environ.get("TEMP") or "/tmp" tmp = os.environ.get("TMPDIR") or os.environ.get("TMP") or os.environ.get("TEMP") or _SYSTEM_TMP
cands.append(os.path.join(tmp.rstrip("/") or "/", name)) cands.append(os.path.join(tmp.rstrip("/") or "/", name))
cands.append(os.path.join("/tmp", name)) cands.append(os.path.join(_SYSTEM_TMP, name))
roots, seen = [], set() roots, seen = [], set()
for c in cands: for c in cands:
real = os.path.realpath(c) real = os.path.realpath(c)
@@ -63,20 +81,47 @@ def _session_file_live(data: dict, pid) -> bool:
return False return False
except OSError: except OSError:
pass pass
# startedAt is epoch ms: a finite positive number. A bool, a string, NaN
# (every comparison with it is false), an infinity or an int too large
# for a float is no time at all.
started = data.get("startedAt")
if isinstance(started, bool) or not isinstance(started, (int, float)):
return True
try:
started = float(started) / 1000.0
except OverflowError:
return True
if not math.isfinite(started) or started <= 0:
return True
# lstart in UTC: a local time is ambiguous in the hour a DST change repeats.
try: try:
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True,
timeout=5, env=dict(os.environ, LC_ALL="C")).stdout timeout=5, env=dict(os.environ, LC_ALL="C", TZ="UTC0")).stdout
lstart = time.mktime(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y")) lstart = calendar.timegm(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
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
def _inside(root: str, path: str) -> bool:
"""`path` is reached from `root` through real directories only: no
component between the two is a symlink, and its parent resolves inside
the resolved root. The root may be a link itself (/tmp -> /private/tmp,
a linked ~/.claude), and so may `path`: _remove then takes the link alone."""
cur = root
for part in os.path.relpath(os.path.dirname(path), root).split(os.sep):
cur = os.path.join(cur, part)
try:
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
except OSError:
return False
real_root = os.path.realpath(root)
return os.path.realpath(os.path.dirname(path)).startswith(real_root.rstrip(os.sep) + os.sep)
def _remove(path: str) -> None: def _remove(path: str) -> None:
try: try:
if os.path.islink(path) or not os.path.isdir(path): if os.path.islink(path) or not os.path.isdir(path):
@@ -253,45 +298,66 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
pass pass
return None return None
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list: def _candidates(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Every existing path that belongs to this session alone (Issue #31). """(root, path) of every existing path that belongs to this session
Shared files that merely mention it (history.jsonl, ~/.claude.json, alone (Issue #31), with the root (config, projects or tmp) it was
backups, paste-cache) are not the session's and are never listed.""" found under. Shared files that merely mention it (history.jsonl,
~/.claude.json, backups, paste-cache) are not the session's and are
never listed."""
# Checked before any path is built: whole directories named by the id # Checked before any path is built: whole directories named by the id
# are removed, so "", "." or ".." would reach file-history/ itself. # are removed, so "", "." or ".." would reach file-history/ itself,
if not _plausible_id(uuid): # and "memory" the auto memory. Only a uuid names a session.
uuid = _canonical(uuid)
if not uuid:
return [] return []
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
paths = [] found = []
for pdir in _projects_dirs(ctx): for pdir in _projects_dirs(ctx):
base = os.path.join(pdir, ctx.ws_key, uuid) base = os.path.join(pdir, ctx.ws_key, uuid)
paths += [p for p in (base + ".jsonl", base) if os.path.lexists(p)] found += [(pdir, p) for p in (base + ".jsonl", base) if os.path.lexists(p)]
paths += [p for p in (os.path.join(cfg, "file-history", uuid), found += [(cfg, p) for p in (os.path.join(cfg, "file-history", uuid),
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)] os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
job = self._owned_job(uuid, ctx) job = self._owned_job(uuid, ctx)
if job: if job:
paths.append(job) found.append((cfg, job))
for path, pid, data in self._session_files(uuid, ctx): for path, pid, data in self._session_files(uuid, ctx):
if not _session_file_live(data, pid): if not _session_file_live(data, pid):
# Keys before the session file: the file is what ties them to # Keys before the session file: the file is what ties them to
# this session, so it must outlive them. # this session, so it must outlive them.
paths += _key_files(path) + [path] found += [(cfg, p) for p in _key_files(path) + [path]]
for root in _tmp_roots(): for root in _tmp_roots():
try: try:
keys = sorted(os.listdir(root)) keys = sorted(os.listdir(root))
except OSError: except OSError:
continue continue
paths.extend(p for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)) found += [(root, p) for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)]
return paths return found
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
# What lies behind a linked dir below its root is not the session's:
# it is kept, and residual_artifacts reports it.
return [p for root, p in self._candidates(uuid, ctx) if _inside(root, p)]
def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool: def has_artifacts(self, uuid: str, ctx: DiscoveryContext) -> bool:
# Leftovers count without the transcript (a pre-#31 purge left them). # Leftovers count without the transcript (a pre-#31 purge left them),
return bool(self._owned_paths(uuid, ctx)) # and so does one behind a link: the purge keeps it, but picking
# claude is what gets it reported as residual.
return bool(self._candidates(uuid, ctx))
def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool: def conversation_in_use(self, uuid: str, ctx: DiscoveryContext) -> bool:
if conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]): # No claude process holds a name that is not a uuid; searching
# command lines for one ("a/b", "memory") only finds strangers.
canon = _canonical(uuid)
if not canon:
return False
# claude's own spelling and the one asked about, on a command line.
if any(conversation_in_use(u, [self.artifact_path(canon, ctx)]) for u in {canon, uuid}):
return True return True
return any(_session_file_live(data, pid) for _, pid, data in self._session_files(uuid, ctx)) return any(_session_file_live(data, pid) for _, pid, data in self._session_files(canon, ctx))
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
return [f"a claude process whose session file under {os.path.join(_config_dir(ctx), 'sessions')}/ names it"] \
+ super().in_use_hints(_canonical(uuid) or uuid, ctx)
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Best effort: never raises (stop runs this inside its registry # Best effort: never raises (stop runs this inside its registry
@@ -299,6 +365,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
# shows up in residual_artifacts instead. # shows up in residual_artifacts instead.
# Ownership evidence (a job's state.json, a session file) is removed # 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. # only after what it vouches for, so a retry can still find the rest.
uuid = _canonical(uuid)
if not uuid:
return []
purged = [] purged = []
job = self._owned_job(uuid, ctx) job = self._owned_job(uuid, ctx)
for path in self._owned_paths(uuid, ctx): for path in self._owned_paths(uuid, ctx):
@@ -318,7 +387,8 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
roots (two levels), plus the content-matched jobs dir and session roots (two levels), plus the content-matched jobs dir and session
files with their (masked) key files. projects is not walked deeper; files with their (masked) key files. projects is not walked deeper;
it can be very large.""" it can be very large."""
if not _plausible_id(uuid): uuid = _canonical(uuid)
if not uuid:
return [] return []
cfg = _config_dir(ctx) cfg = _config_dir(ctx)
pdirs = _projects_dirs(ctx) pdirs = _projects_dirs(ctx)
@@ -337,18 +407,11 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n) found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n)
except OSError: except OSError:
pass pass
job = self._owned_job(uuid, ctx) # Every candidate, inside its root or behind a link (never purged).
if job: found.update(_label(p) for _, p in self._candidates(uuid, ctx))
found.add(job)
for path, _, _ in self._session_files(uuid, ctx): for path, _, _ in self._session_files(uuid, ctx):
found.add(path) found.add(path)
found.update(_label(k) for k in _key_files(path)) found.update(_label(k) for k in _key_files(path))
for root in _tmp_roots():
try:
tkeys = os.listdir(root)
except OSError:
continue
found.update(p for p in (os.path.join(root, k, uuid) for k in tkeys) if os.path.lexists(p))
return sorted(found) return sorted(found)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
+5
View File
@@ -308,6 +308,11 @@ class BaseAgentAdapter:
"""True while a live process still references the conversation.""" """True while a live process still references the conversation."""
return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]) return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)])
def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list:
"""Where to look for what holds the conversation, shown when a purge
is refused because it is in use."""
return [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"]
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
raise NotImplementedError raise NotImplementedError
+1 -1
View File
@@ -62,7 +62,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
| 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | | 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) |
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
| 3 | `--purge-conversation` without `--yes` | | 3 | `--purge-conversation` without `--yes` |
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain (nothing was changed) | | 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down |
| other | The registry update after the stop (YAML / DB write) failed and its code is passed through | | other | The registry update after the stop (YAML / DB write) failed and its code is passed through |
Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent). Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent).
@@ -289,12 +289,12 @@ fi
# registry lock. Any answer but "free" (0) keeps everything. # registry lock. Any answer but "free" (0) keeps everything.
if [ "$PURGE" = "1" ] && [ -n "$PURGE_UUID" ]; then if [ "$PURGE" = "1" ] && [ -n "$PURGE_UUID" ]; then
inuse_rc=0 inuse_rc=0
"$(_delegate_py_bin)" -m lib_py.agents in-use "$AGENT" "$PURGE_UUID" "$TARGET_CWD" --wait 5 >/dev/null || inuse_rc=$? inuse_hints="$("$(_delegate_py_bin)" -m lib_py.agents in-use "$AGENT" "$PURGE_UUID" "$TARGET_CWD" --wait 5 2>&1 >/dev/null)" || inuse_rc=$?
if [ "$inuse_rc" != "0" ]; then if [ "$inuse_rc" != "0" ]; then
echo "ERROR: conversation $PURGE_UUID is still held by a live agent process (rc=$inuse_rc)." >&2 echo "ERROR: conversation $PURGE_UUID is still held by a live agent process (rc=$inuse_rc)." >&2
echo " Refusing purge — records preserved (no state was modified)." >&2 echo " Refusing purge — records preserved: the registry row and the conversation files are untouched (the session itself may already be down)." >&2
echo " For claude, see the session files naming it under ~/.claude/sessions/." >&2 [ -n "$inuse_hints" ] && printf '%s\n' "$inuse_hints" >&2
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; records preserved" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
exit 4 exit 4
fi fi
fi fi
+3 -3
View File
@@ -209,12 +209,12 @@ def test_purge_artifacts_composite(tmp_path):
# 1. Claude # 1. Claude
claude_adapter = get_adapter('claude') claude_adapter = get_adapter('claude')
claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home) claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
c_path = claude_adapter.artifact_path('uuid-c', claude_ctx) c_path = claude_adapter.artifact_path('0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c', claude_ctx)
os.makedirs(os.path.dirname(c_path), exist_ok=True) os.makedirs(os.path.dirname(c_path), exist_ok=True)
with open(c_path, 'w') as f: with open(c_path, 'w') as f:
f.write('{"sessionId": "uuid-c"}') f.write('{"sessionId": "0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c"}')
assert os.path.exists(c_path) assert os.path.exists(c_path)
purged_c = claude_adapter.purge_artifacts('uuid-c', claude_ctx) purged_c = claude_adapter.purge_artifacts('0c1a5f3e-6b2d-4c8e-9a7f-1d2e3f4a5b6c', claude_ctx)
assert len(purged_c) == 1 assert len(purged_c) == 1
assert not os.path.exists(c_path) assert not os.path.exists(c_path)
+263 -4
View File
@@ -47,6 +47,29 @@ def _fresh_id() -> str:
return ident return ident
@pytest.fixture(autouse=True)
def own_tmp_roots(tmp_path, monkeypatch):
"""Every tmp root the claude adapter may look in stays inside the test:
TMP/TEMP/TMPDIR and the fixed /tmp fallback, here and in every python a
test starts (a sitecustomize on PYTHONPATH sets the fallback there)."""
from lib_py.agents.adapters import claude as claude_mod
sys_tmp, tmpdir, shim = tmp_path / "systmp", tmp_path / "tmpdir", tmp_path / "pyshim"
for d in (sys_tmp, tmpdir, shim):
d.mkdir()
monkeypatch.setattr(claude_mod, "_SYSTEM_TMP", str(sys_tmp))
monkeypatch.setenv("TMPDIR", str(tmpdir))
monkeypatch.delenv("TMP", raising=False)
monkeypatch.delenv("TEMP", raising=False)
(shim / "sitecustomize.py").write_text(
"try:\n"
" from lib_py.agents.adapters import claude\n"
f" claude._SYSTEM_TMP = {str(sys_tmp)!r}\n"
"except Exception:\n"
" pass\n")
monkeypatch.setenv("PYTHONPATH", f"{shim}:{os.environ.get('PYTHONPATH', '')}")
return sys_tmp
@pytest.fixture @pytest.fixture
def conv() -> str: def conv() -> str:
return _fresh_id() return _fresh_id()
@@ -239,18 +262,187 @@ def test_claude_residual_lists_leftovers_then_nothing(home, tmp_path, conv, othe
assert adapter.residual_artifacts(conv, ctx) == [str(stray)] assert adapter.residual_artifacts(conv, ctx) == [str(stray)]
@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b"]) @pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b", "memory", " ", "notes",
"12345678-1234-1234-1234-12345678901g", "{conv} ", "x{conv}"])
def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad): def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
bad = bad.format(conv=conv)
ws = tmp_path / "ws" ws = tmp_path / "ws"
ws.mkdir() ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp") t = _tree(home, ws, conv, tmp_path / "ctmp")
# Claude Code's auto memory sits beside the session dirs, under the one
# non-uuid name there.
memory = home / ".claude" / "projects" / workspace_key(str(ws)) / "memory" / "MEMORY.md"
memory.parent.mkdir()
memory.write_text("m")
named = [home / ".claude" / d / bad / "x" for d in ("file-history", "session-env")
if bad.strip(". ") and "/" not in bad and "*" not in bad]
for p in named:
p.parent.mkdir(parents=True)
p.write_text("x")
adapter = get_adapter("claude") adapter = get_adapter("claude")
ctx = _ctx(ws, home) ctx = _ctx(ws, home)
assert adapter.purge_artifacts(bad, ctx) == [] assert adapter.purge_artifacts(bad, ctx) == []
assert adapter.residual_artifacts(bad, ctx) == [] assert adapter.residual_artifacts(bad, ctx) == []
assert adapter.has_artifacts(bad, ctx) is False assert adapter.has_artifacts(bad, ctx) is False
assert adapter.conversation_in_use(bad, ctx) is False
assert all(p.exists() for p in t.values()) assert all(p.exists() for p in t.values())
assert memory.exists() and all(p.exists() for p in named)
@pytest.mark.parametrize("spell", [str.upper, lambda u: u[:18].upper() + u[18:]], ids=["upper", "mixed"])
def test_any_spelling_of_the_uuid_is_the_lowercase_session(home, tmp_path, conv, live, spell):
"""claude writes its ids in lowercase, and on macOS's default
case-insensitive APFS another spelling names the very same files. Every
check then uses the lowercase id: the live holder is seen, by its session
file or its command line, and a purge takes the session's own paths."""
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid)
other = spell(conv)
assert other != conv
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(other, ctx) is True
for args in (["purge", "claude", other, str(ws)], ["in-use", "claude", other, str(ws)]):
res = _agents_cli(args, ws)
assert res.returncode == 4, res.stdout + res.stderr
assert all(p.exists() for p in t.values())
live.kill()
live.wait()
named = subprocess.Popen(["sh", "-c", "sleep 120", conv])
try:
time.sleep(0.05)
assert adapter.conversation_in_use(other, ctx) is True, "a command line naming the lowercase id"
finally:
named.kill()
named.wait()
assert adapter.has_artifacts(other, ctx) is True
purged = adapter.purge_artifacts(other, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert purged and all(other not in p for p in purged)
assert adapter.residual_artifacts(other, ctx) == []
def test_claude_purge_never_follows_a_linked_dir_below_a_root(home, tmp_path, conv):
"""A symlinked <key> (tmp, projects) or file-history/ below its root is
not followed: what lies behind it stays and is reported as residual."""
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
key = workspace_key(str(ws))
out = tmp_path / "outside"
victims = [out / "tmp" / conv / "keep.txt", out / "proj" / f"{conv}.jsonl", out / "proj" / conv / "keep.txt",
out / "fh" / conv / "keep.txt"]
for v in victims:
v.parent.mkdir(parents=True, exist_ok=True)
v.write_text("keep")
troot = tmp_path / "ctmp" / f"claude-{UID}"
troot.mkdir(parents=True)
links = [troot / "linked-workspace", cfg / "projects" / key, cfg / "file-history"]
for link, target in zip(links, (out / "tmp", out / "proj", out / "fh")):
link.symlink_to(target)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.purge_artifacts(conv, ctx) == []
assert all(v.exists() for v in victims) and all(link.is_symlink() for link in links)
assert adapter.residual_artifacts(conv, ctx) == sorted(
[str(troot / "linked-workspace" / conv), str(cfg / "projects" / key / f"{conv}.jsonl"),
str(cfg / "projects" / key / conv), str(cfg / "file-history" / conv)])
def test_purge_cli_reports_a_linked_orphan_it_will_not_remove(home, tmp_path, conv):
"""A candidate behind a link is all that is left: the CLI still picks
claude, so its residual names it, and removes nothing."""
ws = tmp_path / "ws"
ws.mkdir()
keep = tmp_path / "outside" / conv / "keep.txt"
keep.parent.mkdir(parents=True)
keep.write_text("keep")
troot = tmp_path / "ctmp" / f"claude-{UID}"
troot.mkdir(parents=True)
(troot / "linked-workspace").symlink_to(tmp_path / "outside")
res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 0, res.stdout + res.stderr
assert json.loads(res.stdout) == {"agent": "claude", "purged": [],
"residual": [str(troot / "linked-workspace" / conv)], "in_use": False}
assert keep.exists()
def test_claude_purge_removes_only_the_link_at_the_leaf(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
key = workspace_key(str(ws))
out = tmp_path / "outside"
keep = [out / "tmp" / "keep.txt", out / "proj" / "keep.txt"]
for k in keep:
k.parent.mkdir(parents=True)
k.write_text("keep")
leaves = [tmp_path / "ctmp" / f"claude-{UID}" / key / conv, home / ".claude" / "projects" / key / conv]
for leaf, target in zip(leaves, (out / "tmp", out / "proj")):
leaf.parent.mkdir(parents=True)
leaf.symlink_to(target)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert set(adapter.purge_artifacts(conv, ctx)) == {str(p) for p in leaves}
assert not any(os.path.lexists(p) for p in leaves)
assert all(k.exists() for k in keep)
assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_roots_may_be_links(home, tmp_path, monkeypatch, conv):
"""A root that is itself a link (/tmp -> /private/tmp on macOS, a linked
~/.claude) is the boundary, not a way out of it."""
ws = tmp_path / "ws"
ws.mkdir()
real_cfg, real_tmp = tmp_path / "real-cfg", tmp_path / "real-tmp"
real_cfg.mkdir()
real_tmp.mkdir()
(tmp_path / "cfg-link").symlink_to(real_cfg)
(tmp_path / "tmp-link").symlink_to(real_tmp)
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg-link"))
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "tmp-link"))
t = _tree(home, ws, conv, tmp_path / "tmp-link", cfg=tmp_path / "cfg-link")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert adapter.residual_artifacts(conv, ctx) == []
@pytest.mark.parametrize("name", ["uuid-c", "memory", "notes"])
def test_a_non_uuid_id_reaches_nothing(home, tmp_path, name):
"""Only a uuid names a claude session: no path is built for any other id,
not even the `<id>.jsonl` the adapter took before #31."""
ws = tmp_path / "ws"
ws.mkdir()
proj = home / ".claude" / "projects" / workspace_key(str(ws))
proj.mkdir()
jsonl = proj / f"{name}.jsonl"
jsonl.write_text(json.dumps({"sessionId": name, "cwd": os.path.realpath(str(ws))}) + "\n")
kept = [jsonl, proj / name / "a", home / ".claude" / "file-history" / name / "b"]
for p in kept[1:]:
p.parent.mkdir(parents=True)
p.write_text("x")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.has_artifacts(name, ctx) is False
assert adapter.residual_artifacts(name, ctx) == []
assert adapter.purge_artifacts(name, ctx) == []
res = _agents_cli(["purge", "claude", name, str(ws)], ws)
assert json.loads(res.stdout)["purged"] == [], res.stdout + res.stderr
assert all(p.exists() for p in kept)
def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv): def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv):
@@ -314,6 +506,60 @@ def test_session_file_liveness_fails_safe(monkeypatch, live):
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live" assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live"
def test_liveness_reads_lstart_as_utc(tmp_path, monkeypatch, live):
"""2026-11-01 01:30 happens twice in EST5EDT. A process that started at
the second one (06:30 UTC) shows lstart 01:30 to a local-time ps; read as
the first (05:30 UTC) it predates a session file written at 05:45. ps
runs with TZ=UTC0 instead, which BSD ps and procps both honour."""
import calendar
from lib_py.agents.adapters import claude as claude_mod
fake = tmp_path / "fakebin"
fake.mkdir()
(fake / "ps").write_text('#!/bin/sh\nif [ "$TZ" = UTC0 ]; then echo "Sun Nov 1 06:30:00 2026"; '
'else echo "Sun Nov 1 01:30:00 2026"; fi\n')
(fake / "ps").chmod(0o755)
utc = lambda hhmmss: calendar.timegm(time.strptime(f"2026-11-01 {hhmmss}", "%Y-%m-%d %H:%M:%S")) * 1000 # noqa: E731
try:
with monkeypatch.context() as m:
m.setenv("PATH", f"{fake}:{os.environ['PATH']}")
m.setenv("TZ", "EST5EDT,M3.2.0,M11.1.0")
time.tzset()
assert claude_mod._session_file_live({"startedAt": utc("05:45:00")}, live.pid) is False, "reused pid"
assert claude_mod._session_file_live({"startedAt": utc("06:30:02")}, live.pid) is True
finally:
time.tzset()
@pytest.mark.parametrize("started", ["NaN", float("nan"), float("inf"), float("-inf"), "inf", "1760000000000",
"soon", True, False, None, [1], pytest.param(10 ** 400, id="10**400"),
"<missing>"])
def test_an_unusable_started_at_means_live(live, started):
from lib_py.agents.adapters import claude as claude_mod
data = {} if started == "<missing>" else {"startedAt": started}
assert claude_mod._session_file_live(data, live.pid) is True
assert claude_mod._session_file_live(data, _dead_pid()) is False, "a dead pid holds nothing"
@pytest.mark.parametrize("raw", ["NaN", '"NaN"', "Infinity", "true",
pytest.param("1" + "0" * 400, id="10**400")])
def test_a_live_holder_with_an_unusable_started_at_blocks_the_purge(home, tmp_path, conv, live, raw):
"""json.load turns a bare NaN or Infinity into a float that every
comparison fails, which once made a live holder look stale, and a huge
integer into an int no float can hold."""
ws = tmp_path / "ws"
ws.mkdir()
sfile, key = _session_file(home / ".claude", live.pid, conv)
sfile.write_text(f'{{"pid": {live.pid}, "sessionId": "{conv}", "cwd": "/x", "startedAt": {raw}}}')
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True
res = _agents_cli(["in-use", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert adapter.purge_artifacts(conv, ctx) == []
assert sfile.exists() and key.exists()
def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live): def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live):
ws = tmp_path / "ws" ws = tmp_path / "ws"
ws.mkdir() ws.mkdir()
@@ -349,6 +595,7 @@ def test_claude_config_dir_moves_the_config_root(home, tmp_path, monkeypatch, co
ctx = _ctx(ws, home) ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True assert adapter.conversation_in_use(conv, ctx) is True
assert adapter.in_use_hints(conv, ctx)[0] == f"a claude process whose session file under {cfg / 'sessions'}/ names it"
res = _agents_cli(["purge", "claude", conv, str(ws)], ws) res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr assert res.returncode == 4, res.stdout + res.stderr
assert all(p.exists() for p in t.values()) assert all(p.exists() for p in t.values())
@@ -411,14 +658,14 @@ def test_a_job_dir_that_cannot_be_emptied_keeps_its_state_json(home, tmp_path, m
assert adapter.residual_artifacts(conv, ctx) == [] assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_tmp_root_rules(tmp_path, monkeypatch): def test_claude_tmp_root_rules(tmp_path, monkeypatch, own_tmp_roots):
from lib_py.agents.adapters.claude import _tmp_roots from lib_py.agents.adapters.claude import _tmp_roots
name = f"claude-{UID}" name = f"claude-{UID}"
x, y, z = tmp_path / "x", tmp_path / "y", tmp_path / "z" x, y, z = tmp_path / "x", tmp_path / "y", tmp_path / "z"
for d in (x / name, y / name, z / name): for d in (x / name, y / name, z / name, own_tmp_roots / name):
d.mkdir(parents=True) d.mkdir(parents=True)
real = lambda roots: [os.path.realpath(r) for r in roots] # noqa: E731 real = lambda roots: [os.path.realpath(r) for r in roots] # noqa: E731
sys_tmp = [os.path.realpath(f"/tmp/{name}")] if os.path.isdir(f"/tmp/{name}") else [] sys_tmp = [os.path.realpath(str(own_tmp_roots / name))]
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(x)) monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(x))
monkeypatch.setenv("TMPDIR", str(z) + "/") monkeypatch.setenv("TMPDIR", str(z) + "/")
@@ -438,6 +685,14 @@ def test_claude_tmp_root_rules(tmp_path, monkeypatch):
assert real(_tmp_roots()) == [str(z / name)] + sys_tmp assert real(_tmp_roots()) == [str(z / name)] + sys_tmp
def test_tests_never_reach_the_real_tmp_root(tmp_path, own_tmp_roots):
from lib_py.agents.adapters.claude import _tmp_roots
assert all(os.path.realpath(r).startswith(os.path.realpath(str(tmp_path))) for r in _tmp_roots())
res = subprocess.run([sys.executable, "-c", "from lib_py.agents.adapters import claude; print(claude._SYSTEM_TMP)"],
capture_output=True, text=True, env=_env(), timeout=30)
assert res.stdout.strip() == str(own_tmp_roots), res.stdout + res.stderr
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# in-use CLI and the stop gate # in-use CLI and the stop gate
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -457,6 +712,8 @@ def test_in_use_cli_waits_then_answers(home, tmp_path, conv, live):
assert res.returncode == 4, res.stdout + res.stderr assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True assert json.loads(res.stdout)["in_use"] is True
assert time.monotonic() - t0 >= 1 assert time.monotonic() - t0 >= 1
assert f"session file under {home / '.claude' / 'sessions'}/ names it" in res.stderr
assert f"ps -axww | grep {conv}" in res.stderr
assert _agents_cli(["in-use", "nosuchagent", conv, str(ws)], ws).returncode == 1 assert _agents_cli(["in-use", "nosuchagent", conv, str(ws)], ws).returncode == 1
assert _agents_cli(["in-use", "claude"], ws).returncode == 1 assert _agents_cli(["in-use", "claude"], ws).returncode == 1
@@ -540,6 +797,8 @@ def test_stop_purge_refuses_while_the_claude_process_lives(stop_box, conv):
assert res.returncode == 4, res.stdout + res.stderr assert res.returncode == 4, res.stdout + res.stderr
assert "herdr already dead" in res.stdout assert "herdr already dead" in res.stdout
assert "records preserved" in res.stderr assert "records preserved" in res.stderr
assert f"session file under {stop_box / '.claude' / 'sessions'}/ names it" in res.stderr
assert f"ps -axww | grep {conv}" in res.stderr
assert "purged: " not in res.stdout assert "purged: " not in res.stdout
assert all(p.exists() for p in t.values()) assert all(p.exists() for p in t.values())
assert _row_left(stop_box, name) == (True, True) assert _row_left(stop_box, name) == (True, True)