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)
|
# 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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user