Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8d73825b | ||
|
|
81b6c2ea6b | ||
|
|
dd292c12cb | ||
|
|
c0248b88be | ||
|
|
60779ba1f8 | ||
|
|
e156179822 | ||
|
|
af8ed397f6 | ||
|
|
f943b0db30 | ||
|
|
6e0361cf43 | ||
|
|
bd09c6a133 | ||
|
|
26fb1c4130 |
No files matched your search
@@ -155,7 +155,8 @@ def main():
|
||||
# in-use <agent> <uuid> [workspace] [--wait <sec>] (Issue #31)
|
||||
# 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.
|
||||
# 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:]
|
||||
wait = 0.0
|
||||
if '--wait' in args:
|
||||
@@ -181,6 +182,10 @@ def main():
|
||||
time.sleep(0.25)
|
||||
in_use = adapter.conversation_in_use(uuid, ctx)
|
||||
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)
|
||||
|
||||
else:
|
||||
|
||||
@@ -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 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
|
||||
|
||||
|
||||
_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:
|
||||
"""claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/,
|
||||
file-history/, session-env/, jobs/, projects/)."""
|
||||
@@ -22,6 +36,10 @@ def _projects_dirs(ctx: DiscoveryContext) -> list:
|
||||
return dirs
|
||||
|
||||
|
||||
# Node's os.tmpdir() when no TMPDIR/TMP/TEMP is set; tests point it elsewhere.
|
||||
_SYSTEM_TMP = "/tmp"
|
||||
|
||||
|
||||
def _tmp_roots() -> list:
|
||||
"""The per-uid temp roots claude may have used: ${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)
|
||||
# Node's os.tmpdir() on POSIX, not tempfile.gettempdir() (which probes
|
||||
# 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", name))
|
||||
cands.append(os.path.join(_SYSTEM_TMP, name))
|
||||
roots, seen = [], set()
|
||||
for c in cands:
|
||||
real = os.path.realpath(c)
|
||||
@@ -63,20 +81,47 @@ def _session_file_live(data: dict, pid) -> bool:
|
||||
return False
|
||||
except OSError:
|
||||
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:
|
||||
out = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True,
|
||||
timeout=5, env=dict(os.environ, LC_ALL="C")).stdout
|
||||
lstart = time.mktime(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
|
||||
started = float(data["startedAt"]) / 1000.0
|
||||
timeout=5, env=dict(os.environ, LC_ALL="C", TZ="UTC0")).stdout
|
||||
lstart = calendar.timegm(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
if os.path.islink(path) or not os.path.isdir(path):
|
||||
@@ -253,45 +298,66 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _owned_paths(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""Every existing path that belongs to this session alone (Issue #31).
|
||||
Shared files that merely mention it (history.jsonl, ~/.claude.json,
|
||||
backups, paste-cache) are not the session's and are never listed."""
|
||||
def _candidates(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
"""(root, path) of every existing path that belongs to this session
|
||||
alone (Issue #31), with the root (config, projects or tmp) it was
|
||||
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
|
||||
# are removed, so "", "." or ".." would reach file-history/ itself.
|
||||
if not _plausible_id(uuid):
|
||||
# are removed, so "", "." or ".." would reach file-history/ itself,
|
||||
# and "memory" the auto memory. Only a uuid names a session.
|
||||
uuid = _canonical(uuid)
|
||||
if not uuid:
|
||||
return []
|
||||
cfg = _config_dir(ctx)
|
||||
paths = []
|
||||
found = []
|
||||
for pdir in _projects_dirs(ctx):
|
||||
base = os.path.join(pdir, ctx.ws_key, uuid)
|
||||
paths += [p for p in (base + ".jsonl", base) if os.path.lexists(p)]
|
||||
paths += [p for p in (os.path.join(cfg, "file-history", uuid),
|
||||
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
|
||||
found += [(pdir, p) for p in (base + ".jsonl", base) if os.path.lexists(p)]
|
||||
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)]
|
||||
job = self._owned_job(uuid, ctx)
|
||||
if job:
|
||||
paths.append(job)
|
||||
found.append((cfg, job))
|
||||
for path, pid, data in self._session_files(uuid, ctx):
|
||||
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]
|
||||
found += [(cfg, p) for p in _key_files(path) + [path]]
|
||||
for root in _tmp_roots():
|
||||
try:
|
||||
keys = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
continue
|
||||
paths.extend(p for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p))
|
||||
return paths
|
||||
found += [(root, p) for p in (os.path.join(root, k, uuid) for k in keys) if os.path.lexists(p)]
|
||||
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:
|
||||
# Leftovers count without the transcript (a pre-#31 purge left them).
|
||||
return bool(self._owned_paths(uuid, ctx))
|
||||
# Leftovers count without the transcript (a pre-#31 purge left them),
|
||||
# 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:
|
||||
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 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:
|
||||
# Best effort: never raises (stop runs this inside its registry
|
||||
@@ -299,6 +365,9 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
# 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.
|
||||
uuid = _canonical(uuid)
|
||||
if not uuid:
|
||||
return []
|
||||
purged = []
|
||||
job = self._owned_job(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
|
||||
files with their (masked) key files. projects is not walked deeper;
|
||||
it can be very large."""
|
||||
if not _plausible_id(uuid):
|
||||
uuid = _canonical(uuid)
|
||||
if not uuid:
|
||||
return []
|
||||
cfg = _config_dir(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)
|
||||
except OSError:
|
||||
pass
|
||||
job = self._owned_job(uuid, ctx)
|
||||
if job:
|
||||
found.add(job)
|
||||
# Every candidate, inside its root or behind a link (never purged).
|
||||
found.update(_label(p) for _, p in self._candidates(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)
|
||||
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)
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
|
||||
@@ -308,6 +308,11 @@ class BaseAgentAdapter:
|
||||
"""True while a live process still references the conversation."""
|
||||
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:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -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) |
|
||||
| 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
if [ "$PURGE" = "1" ] && [ -n "$PURGE_UUID" ]; then
|
||||
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
|
||||
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 " For claude, see the session files naming it under ~/.claude/sessions/." >&2
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
echo " Refusing purge — records preserved: the registry row and the conversation files are untouched (the session itself may already be down)." >&2
|
||||
[ -n "$inuse_hints" ] && printf '%s\n' "$inuse_hints" >&2
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; records preserved" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
exit 4
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -209,12 +209,12 @@ def test_purge_artifacts_composite(tmp_path):
|
||||
# 1. Claude
|
||||
claude_adapter = get_adapter('claude')
|
||||
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)
|
||||
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)
|
||||
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 not os.path.exists(c_path)
|
||||
|
||||
|
||||
@@ -47,6 +47,29 @@ def _fresh_id() -> str:
|
||||
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
|
||||
def conv() -> str:
|
||||
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)]
|
||||
|
||||
|
||||
@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):
|
||||
bad = bad.format(conv=conv)
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
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")
|
||||
ctx = _ctx(ws, home)
|
||||
|
||||
assert adapter.purge_artifacts(bad, ctx) == []
|
||||
assert adapter.residual_artifacts(bad, ctx) == []
|
||||
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 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):
|
||||
@@ -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"
|
||||
|
||||
|
||||
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):
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
@@ -349,6 +595,7 @@ def test_claude_config_dir_moves_the_config_root(home, tmp_path, monkeypatch, co
|
||||
ctx = _ctx(ws, home)
|
||||
|
||||
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)
|
||||
assert res.returncode == 4, res.stdout + res.stderr
|
||||
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) == []
|
||||
|
||||
|
||||
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
|
||||
name = f"claude-{UID}"
|
||||
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)
|
||||
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("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
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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 json.loads(res.stdout)["in_use"] is True
|
||||
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", "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 "herdr already dead" in res.stdout
|
||||
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 all(p.exists() for p in t.values())
|
||||
assert _row_left(stop_box, name) == (True, True)
|
||||
|
||||
Reference in new issue
Block a user