Compare commits

..
10 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5.5 6f4f40a4ad test(issue31): stop purges a session kept under CLAUDE_CONFIG_DIR
End to end through stop_session.sh with CLAUDE_CONFIG_DIR alone and with a
matching CLAUDE_PROJECT_DIR; plus the DiscoveryContext default. The
config-only stop case and the default check fail on 872d5f1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:29:16 +00:00
GodopuandClaude Opus 5.5 f0af98971e fix(agents): the claude projects root follows CLAUDE_CONFIG_DIR too (Issue #31, review)
stop resolves the uuid before it purges, through lib.sh's exported
CLAUDE_PROJECT_DIR and DiscoveryContext.claude_dir, both of which defaulted
to $HOME/.claude/projects. With CLAUDE_CONFIG_DIR alone the transcript under
<cfg>/projects was not found, so stop removed the row and purged nothing.
Both defaults are now ${CLAUDE_CONFIG_DIR:-<home>/.claude}/projects; an
explicit CLAUDE_PROJECT_DIR still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:29:16 +00:00
GodopuandClaude Opus 5.5 872d5f12fb test(issue31): CLAUDE_CONFIG_DIR and partial-failure purges
Config-only and config+project overrides: a live session is refused and a
stopped one cleaned up. A key that cannot be removed keeps its session file;
a job dir that cannot be emptied keeps state.json; both are reported and
removed on retry. 5 of these fail on 8edb8d6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 57830cf00a docs(stop): the workflow and the state diagram say a purge removes the row (Issue #31)
SKILL.md lines 52 and 83 still said a purge leaves a `terminated` row. The
two blocks' inventory shas move with them (#42), and so does the
passthrough line of stop's heredoc SystemExit(1) (310 -> 327, after the
process gate). Line counts are unchanged, so no other block moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 f542596975 fix(agents): a claude purge keeps ownership evidence until last (Issue #31, review)
A session file is what ties a pid's key file to the session, and a job's
state.json what ties the job dir: removing them first left the rest
unattributable after a partial failure (residual: none, and a retry could
not find it). Keys now go before their session file, which stays while any
key does; a job dir is emptied with state.json last and kept if anything
stays. residual lists a session file's key files (masked). A startedAt of 0
or less cannot tell a reused pid and counts as live; the sessions/ listdir
no longer raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 8a4f651bdf fix(agents): claude follows CLAUDE_CONFIG_DIR (Issue #31, review)
CLAUDE_CONFIG_DIR moves claude's whole config root, sessions/ included, so a
live session started under it was not seen and a stopped one left
file-history and the rest behind. The config root is now CLAUDE_CONFIG_DIR,
else <home>/.claude; the purge and residual scans look at ctx.claude_dir and
the root's own projects/, each once. The test guard drops the variable for
every test like the other agent-home variables (test_issue55 l1 requires
every *_DIR the adapters read to be accounted for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:40 +00:00
GodopuandClaude Opus 5.5 8edb8d6f0f test(issue31): purge, residual and the stop process gate
17 of the 25 cases fail on 26e2c43; the other 8 pin invariants (other
sessions, shared files, malformed ids, a prefix-twin job dir).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 6328e67469 docs(stop): a purge removes the row; exit 4 covers a live agent (Issue #31)
The prose said a purge leaves a `terminated` row; the code removes it, and
the monitor never marks a stopped row. The step-5 list names what a claude
purge removes. The two code blocks (lines 52, 83) still say `terminated`:
changing them changes their inventory sha, which waits for approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 b3461377ae fix(agents): codex, grok and opencode report their residual (Issue #31)
Their purge is unchanged; what it leaves is now reported: codex names and
thread rows (state_*, thread_history_*; logs_* is not scanned), grok
memory-v2 observations, opencode event/todo rows. db_rows_naming opens the
DB read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
GodopuandClaude Opus 5.5 ae98cf18c4 fix(stop): a purge waits for the agent process, not only the herdr session (Issue #31)
A closed herdr session is not an exited agent, and the `herdr already dead`
path ran no check at all, so stop could remove the files of a live claude
and then its row. New `python -m lib_py.agents in-use <agent> <uuid> [ws]
[--wait s]` gives the purge CLI's own verdict; stop asks it outside the
registry lock and, unless it answers free, exits 4 with nothing changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:06 +00:00
12 changed files with 933 additions and 51 deletions

No files matched your search

+2 -1
View File
@@ -74,7 +74,8 @@ _MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
# Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$HOME}"
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
# claude keeps projects/ under its config root, which CLAUDE_CONFIG_DIR moves (Issue #31).
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects}"
if [ -z "${LOCAL_BIN:-}" ]; then
if [ -x "$HOME_DIR/bin/herdr" ]; then
LOCAL_BIN="$HOME_DIR/bin"
+33 -1
View File
@@ -1,6 +1,6 @@
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
import os, sys, json, shlex
import os, sys, json, shlex, time
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
# `purge auto` probes adapters in this order; opencode last because its
@@ -151,6 +151,38 @@ def main():
print(json.dumps({"agent": name, "purged": purged, "residual": residual, "in_use": False}))
sys.exit(0)
elif cmd == 'in-use':
# 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.
args = sys.argv[2:]
wait = 0.0
if '--wait' in args:
i = args.index('--wait')
try:
wait = float(args[i + 1])
except (IndexError, ValueError):
print("ERROR: --wait requires seconds", file=sys.stderr)
sys.exit(1)
del args[i:i + 2]
agent_name = args[0] if args else ''
uuid = args[1] if len(args) > 1 else ''
workspace = args[2] if len(args) > 2 and args[2] else os.getcwd()
adapter = get_adapter(agent_name)
if not adapter or not uuid:
print("ERROR: in-use requires <agent> <uuid>", file=sys.stderr)
sys.exit(1)
from lib_py.agents.base import DiscoveryContext
ctx = DiscoveryContext(workspace, agent_name)
deadline = time.monotonic() + wait
in_use = adapter.conversation_in_use(uuid, ctx)
while in_use and time.monotonic() < deadline:
time.sleep(0.25)
in_use = adapter.conversation_in_use(uuid, ctx)
print(json.dumps({"agent": agent_name, "in_use": in_use}))
sys.exit(4 if in_use else 0)
else:
print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr)
sys.exit(1)
+92 -31
View File
@@ -5,7 +5,21 @@ from lib_py.verify_session import workspace_key
def _config_dir(ctx: DiscoveryContext) -> str:
return os.path.join(ctx.home_dir, ".claude")
"""claude's config root: CLAUDE_CONFIG_DIR moves all of it (sessions/,
file-history/, session-env/, jobs/, projects/)."""
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(ctx.home_dir, ".claude")
def _projects_dirs(ctx: DiscoveryContext) -> list:
"""ctx.claude_dir (CLAUDE_PROJECT_DIR, else the config root's projects/)
and the config root's own projects/, each once."""
dirs, seen = [], set()
for d in (ctx.claude_dir, os.path.join(_config_dir(ctx), "projects")):
real = os.path.realpath(d)
if real not in seen:
seen.add(real)
dirs.append(d)
return dirs
def _tmp_roots() -> list:
@@ -56,6 +70,8 @@ def _session_file_live(data: dict, pid) -> bool:
started = float(data["startedAt"]) / 1000.0
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
@@ -71,6 +87,41 @@ def _remove(path: str) -> None:
pass
def _key_files(session_file: str) -> list:
"""The key files of the pid a sessions/<pid>.json is named after,
matched by name only; their content is never read."""
d, name = os.path.split(session_file)
stem = name[:-len(".json")]
if not stem.isdigit():
return []
try:
names = sorted(os.listdir(d))
except OSError:
return []
return [os.path.join(d, n) for n in names if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n)]
def _remove_job(job: str) -> None:
"""state.json goes last: it is the only proof that the dir is this
session's, so a dir that could not be emptied keeps it."""
if os.path.islink(job):
_remove(job)
return
try:
names = os.listdir(job)
except OSError:
return
for n in names:
if n != "state.json":
_remove(os.path.join(job, n))
try:
left = os.listdir(job)
except OSError:
return
if left in ([], ["state.json"]):
_remove(job)
def _label(path: str) -> str:
"""How a path is printed: a session key file is named by pid only, its
hex part is not shown."""
@@ -211,23 +262,20 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
if not _plausible_id(uuid):
return []
cfg = _config_dir(ctx)
jsonl = self.artifact_path(uuid, ctx)
paths = [p for p in (jsonl, jsonl[:-len(".jsonl")],
os.path.join(cfg, "file-history", uuid),
os.path.join(cfg, "session-env", uuid)) if os.path.lexists(p)]
paths = []
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)]
job = self._owned_job(uuid, ctx)
if job:
paths.append(job)
for path, pid, data in self._session_files(uuid, ctx):
if _session_file_live(data, pid):
continue
paths.append(path)
stem = os.path.basename(path)[:-len(".json")]
sessions = os.path.dirname(path)
if stem.isdigit():
# The key file is matched by name only; its content is never read.
paths.extend(os.path.join(sessions, n) for n in sorted(os.listdir(sessions))
if re.fullmatch(rf"{stem}\.[0-9a-f]{{64}}\.key", n))
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]
for root in _tmp_roots():
try:
keys = sorted(os.listdir(root))
@@ -249,9 +297,17 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
# Best effort: never raises (stop runs this inside its registry
# transaction). What could not be removed is not listed here and
# shows up in residual_artifacts instead.
# Ownership evidence (a job's state.json, a session file) is removed
# only after what it vouches for, so a retry can still find the rest.
purged = []
job = self._owned_job(uuid, ctx)
for path in self._owned_paths(uuid, ctx):
_remove(path)
if path == job:
_remove_job(path)
elif path.endswith(".json") and any(os.path.lexists(k) for k in _key_files(path)):
continue
else:
_remove(path)
if not os.path.lexists(path) and _label(path) not in purged:
purged.append(_label(path))
return purged
@@ -260,28 +316,33 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
"""What still carries this session: names holding the uuid under the
config root, in projects (two levels: key, then entry) and in the tmp
roots (two levels), plus the content-matched jobs dir and session
files. projects is not walked deeper; it can be very large."""
files with their (masked) key files. projects is not walked deeper;
it can be very large."""
if not _plausible_id(uuid):
return []
cfg = _config_dir(ctx)
found = set(scan_names(cfg, uuid, skip=(os.path.join(cfg, "projects"), ctx.claude_dir)))
try:
keys = sorted(os.listdir(ctx.claude_dir))
except OSError:
keys = []
for key in keys:
kdir = os.path.join(ctx.claude_dir, key)
if uuid in key:
found.add(kdir)
elif os.path.isdir(kdir):
try:
found.update(os.path.join(kdir, n) for n in os.listdir(kdir) if uuid in n)
except OSError:
pass
pdirs = _projects_dirs(ctx)
found = set(scan_names(cfg, uuid, skip=pdirs))
for pdir in pdirs:
try:
keys = sorted(os.listdir(pdir))
except OSError:
keys = []
for key in keys:
kdir = os.path.join(pdir, key)
if uuid in key:
found.add(kdir)
elif os.path.isdir(kdir):
try:
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)
found.update(path for path, _, _ in self._session_files(uuid, ctx))
for path, _, _ in self._session_files(uuid, ctx):
found.add(path)
found.update(_label(k) for k in _key_files(path))
for root in _tmp_roots():
try:
tkeys = os.listdir(root)
+12 -1
View File
@@ -1,6 +1,6 @@
import os, json, glob, sqlite3, subprocess
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key
class CodexAgentAdapter(BaseAgentAdapter):
@@ -158,6 +158,17 @@ class CodexAgentAdapter(BaseAgentAdapter):
pass
return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]:
# Issue #31: names under CODEX_HOME plus thread rows. logs_*.sqlite is
# not scanned (no thread index: a full scan of a large file).
root = self._codex_home(ctx)
found = scan_names(root, uuid)
for db in sorted(glob.glob(f"{root}/state_*.sqlite")):
found += db_rows_naming(db, uuid, ("thread_id",), id_tables=(("threads", "id"),))
for db in sorted(glob.glob(f"{root}/thread_history_*.sqlite")):
found += db_rows_naming(db, uuid, ("thread_id",))
return found
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --dangerously-bypass-approvals-and-sandbox"
@@ -1,7 +1,7 @@
import os, json, glob, shutil
from typing import Optional, Any
from urllib.parse import quote
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names
from lib_py.verify_session import workspace_key
@@ -108,6 +108,11 @@ class GrokAgentAdapter(BaseAgentAdapter):
purged.append(self.artifact_path(uuid, ctx))
return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: reported, not removed - memory-v2 observations are the
# shared store of grok's memory pipeline.
return scan_names(f"{ctx.home_dir}/.grok", uuid)
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
if session_uuid:
return f"{binary} --session-id {session_uuid} --permission-mode bypassPermissions"
@@ -1,6 +1,6 @@
import os, sys, sqlite3, shutil
from typing import Optional, Any, List
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, scan_names, db_rows_naming
from lib_py.verify_session import workspace_key
_PROVIDER_ENV_VARS = (
@@ -194,6 +194,13 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
sys.stderr.write(f"WARN: purge opencode db records failed: {e}\n")
return purged
def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Issue #31: the purge leaves event-sourced rows (event, todo, ...);
# deleting those is not known to be safe, so they are reported.
found = scan_names(f"{ctx.home_dir}/.local/share/opencode", uuid)
return found + db_rows_naming(self._resolve_db(ctx), uuid, ("session_id", "aggregate_id"),
id_tables=(("session", "id"),))
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
return f"{binary} --auto --agent build"
+38 -1
View File
@@ -2,6 +2,7 @@
import os, json, sqlite3, shutil, subprocess
from typing import Optional, Dict, Any, List
from urllib.parse import quote
from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key
@@ -107,6 +108,40 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
return sorted(found)
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list:
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
conversation in one of `columns`, or in the id column of an `id_tables`
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped."""
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
return []
try:
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
except Exception:
return []
found = []
try:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
qt = table.replace('"', '""')
try:
cols = {r[1] for r in conn.execute(f'PRAGMA table_info("{qt}")')}
except Exception:
continue
wanted = [c for c in columns if c in cols]
wanted += [c for t, c in id_tables if t == table and c in cols and c not in wanted]
for col in wanted:
try:
n = conn.execute(f'SELECT count(*) FROM "{qt}" WHERE "{col}"=?', (uuid,)).fetchone()[0]
except Exception:
continue
if n:
found.append(f"{db} ({table}.{col}: {n} rows)")
except Exception:
pass
finally:
conn.close()
return found
class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name
@@ -121,7 +156,9 @@ class DiscoveryContext:
self.workspace = workspace
self.agent_name = agent_name
self.home_dir = resolve_home(home_dir)
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{self.home_dir}/.claude/projects")
# projects/ lives under claude's config root, which CLAUDE_CONFIG_DIR moves.
config_root = os.environ.get("CLAUDE_CONFIG_DIR") or f"{self.home_dir}/.claude"
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{config_root}/projects")
self.epoch = epoch
self.row = row or {}
self.mode = mode
+6 -6
View File
@@ -49,7 +49,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --agent "$AGENT" --reason api_error
# 3. Stop gracefully + clean up on-disk conversation (DANGEROUS)
# — this prevents any future resume (status=terminated, resumable=false).
# — this prevents any future resume and removes the session's registry row (YAML+DB).
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --agent "$AGENT" --purge-conversation --yes
```
@@ -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 survived the kill chain |
| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain (nothing was changed) |
| 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).
@@ -80,20 +80,20 @@ while its registry row survives.
```
running ──(stop default / --reason)────────► stopped (resumable:true, conv preserved)
running ──(stop --purge-conversation --yes)► terminated (resumable:false, conv deleted)
running ──(stop --purge-conversation --yes)► row removed (YAML+DB; conv deleted)
stopped ──(stop default … again)───────────► stopped (idempotent no-op)
```
Fields written in STOP mode: `status: stopped`, `stopped_at`, `stopped_at_epoch`, `stop_reason`, `termination_mode: graceful`, `claude_session_id_own`/`agy_conversation_id_own` and `resumable: true`.
If `--purge-conversation` is used: `status: terminated`, `terminated_at`, `terminated_at_epoch`, `termination_mode: purge` and `resumable: false`.
If `--purge-conversation` is used: the row is removed from the YAML and the DB — no `terminated` row is left.
The script:
1. Verifies the session is in agent-sessions.yaml
2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry
3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit)
4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback
5. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or, for agy, `~/.gemini/antigravity-cli/conversations/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.lock` (the lock only once no live process references the conversation — Issue #10)
5. For `purge-conversation`: deletes, for claude, every path the session owns — `projects/<key>/<uuid>.jsonl` and `<uuid>/`, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` (when its `state.json` names the session), stale `sessions/<pid>.json` (+ that pid's `.key`) and `<tmp>/claude-<uid>/*/<uuid>/`, then prints anything still carrying the UUID as `WARN residual:`; shared files such as `history.jsonl` are not rewritten (Issue #31). Or, for agy, `~/.gemini/antigravity-cli/conversations/<uuid>.db` with its `-wal`/`-shm`/`-journal` sidecars, `brain/<uuid>/`, `annotations/<uuid>.pbtxt` and `presence/<uuid>.lock` (the lock only once no live process references the conversation — Issue #10)
6. Updates the YAML entry and SQLite database atomically
7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry
@@ -120,7 +120,7 @@ Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control root,
- **Don't delete on-disk artifacts by default** — the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-mux-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance.
- **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but herdr is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "herdr already dead, just update YAML" and proceed.
- **Don't delete the `claude_session_id_own: null` placeholder** — when the user creates a fresh session with `multi-agent-mux-create` and never sent a message, the entry has `claude_session_id_own: null`. Stopping must preserve that field.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `herdr ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `herdr ls != yaml` reconciliation. That's expected — let the monitor run: it leaves a `stopped` row as it is (drift A only marks a `running` row whose herdr is gone as `terminated`), and a purged row is gone.
## Verification
@@ -18,8 +18,8 @@
# 셋 다 실패하면 exit 2)
# --herdr-session <name> — isolated herdr session name (alias: --herdr-server)
# --reason <reason> — 상태 전이 사유 (stop_reason). 기본값 manual_stop
# --purge-conversation — 디스크의 conversation artifact 까지 삭제.
# status=terminated, resumable=false 로 전이하며
# --purge-conversation — 디스크의 conversation artifact 와 세션 부속 경로까지
# 삭제하고 레지스트리 행(YAML+DB)을 지우며
# resume 불가. --yes 없이는 확인 프롬프트(exit 3)
# --yes — --purge-conversation 의 확인 프롬프트 생략
#
@@ -30,7 +30,7 @@
# Exit codes:
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 4 = purge aborted (herdr session survived the kill chain)
# 4 = purge aborted (herdr session or an agent process holding the conversation survived the kill chain)
set -euo pipefail
# shellcheck disable=SC1091
@@ -56,7 +56,7 @@ Arguments:
read from its registry row, not from where you stand.
--reason <reason> — stop_reason field (default: manual_stop)
--purge-conversation — also delete on-disk conversation artifacts;
status becomes terminated and resume is impossible
the registry row is removed and resume is impossible
--yes — skip the --purge-conversation confirmation prompt
EOF
}
@@ -282,6 +282,23 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
fi
fi
# Purge process gate (Issue #31): a closed herdr session is not an exited
# agent - the process may still be shutting down, or may have outlived a dead
# herdr ("herdr already dead" above runs no gate at all). The purge asks the
# adapter's own in-use verdict, the one the purge CLI uses, outside the
# 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=$?
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"
exit 4
fi
fi
# INVARIANT: Terminal events for a job must be published AFTER atomic_dump_yaml updates the session status
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \
+1 -1
View File
@@ -18,7 +18,7 @@ import tempfile
# The pane-inherited variables conftest._isolate_agent_homes drops for every
# test, so HOME (which the tests set) governs, as it does in a plain shell.
DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CODEX_HOME", "LOCAL_BIN")
DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "LOCAL_BIN")
# Read by the code under test but left alone, on purpose: HOME is where the
# real herdr tests and the shim bootstrap find the server socket, and it is
# what the suite's own tests set for an isolated home; XDG_CONFIG_HOME and
+3 -3
View File
@@ -413,7 +413,7 @@
"start": 42,
"end": 55,
"lang": "bash",
"sha": "27ebf150b92a",
"sha": "301c347ff881",
"class": "c",
"kind": "invocation",
"evidence": [
@@ -429,7 +429,7 @@
"start": 81,
"end": 85,
"lang": "-",
"sha": "a2b56ac238ae",
"sha": "a973676cb2d1",
"class": "c",
"kind": "diagram",
"evidence": [
@@ -599,7 +599,7 @@
"other"
],
"passthrough": {
"1": "multi-agent-mux-stop/scripts/stop_session.sh:310"
"1": "multi-agent-mux-stop/scripts/stop_session.sh:327"
}
},
"multi-agent-mux-create/scripts/create_session.sh": {
+711
View File
@@ -0,0 +1,711 @@
#!/usr/bin/env python3
"""Issue #31 — a claude purge removes every path the session owns and reports
what still carries the uuid; stop and orc_onboard both go through it.
Before #31 the claude adapter removed only `<uuid>.jsonl`. file-history,
session-env, `projects/<key>/<uuid>/`, the daemon job dir, a stale
`sessions/<pid>.json` and the tmp scratch dir survived, and with no
`residual_artifacts` the purge printed `residual: none`. A live `claude -c`
names no uuid on its command line, so only `sessions/<pid>.json` shows it is
running: the purge CLI and stop both refuse while that process lives.
codex, grok and opencode report their leftovers (their purge is unchanged).
Every test makes fresh ids: conversation_in_use scans every process on the
host, so a fixed id here is named by anything carrying this file's text.
"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
import time
import uuid as uuidlib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
ONBOARD_SH = SKILLS / "multi-agent-mux-orc-onboard" / "scripts" / "orc_onboard.sh"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.base import DiscoveryContext # noqa: E402
from lib_py.verify_session import workspace_key # noqa: E402
SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID")
UID = os.getuid()
def _fresh_id() -> str:
ident = str(uuidlib.uuid4())
out = subprocess.run(["ps", "-axo", "pid=,command="], capture_output=True, text=True, timeout=10).stdout
assert ident not in out, f"fresh id {ident} is already named by a live process"
return ident
@pytest.fixture
def conv() -> str:
return _fresh_id()
@pytest.fixture
def other() -> str:
return _fresh_id()
@pytest.fixture
def home(tmp_path, monkeypatch):
"""A claude home of its own: HOME, projects and the claude tmp root."""
h = tmp_path / "home"
(h / ".claude" / "projects").mkdir(parents=True)
monkeypatch.setenv("HOME", str(h))
monkeypatch.setenv("HOME_DIR", str(h))
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(h / ".claude" / "projects"))
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "ctmp"))
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
for k in SESSION_ENV:
monkeypatch.delenv(k, raising=False)
return h
def _dead_pid() -> int:
p = subprocess.Popen(["true"])
p.wait()
return p.pid
@pytest.fixture
def live():
"""A running process standing in for claude; its session file must be
written after it started (startedAt >= its lstart)."""
p = subprocess.Popen(["sleep", "120"])
time.sleep(0.05)
yield p
p.kill()
p.wait()
def _session_file(cfg: Path, pid: int, sid: str, started_ms=None, key=True) -> list:
d = cfg / "sessions"
d.mkdir(parents=True, exist_ok=True)
started_ms = int(time.time() * 1000) if started_ms is None else started_ms
f = d / f"{pid}.json"
f.write_text(json.dumps({"pid": pid, "sessionId": sid, "cwd": "/x", "startedAt": started_ms}))
made = [f]
if key:
k = d / f"{pid}.{uuidlib.uuid4().hex}{uuidlib.uuid4().hex}.key"
k.write_bytes(os.urandom(140))
made.append(k)
return made
def _tree(h: Path, ws: Path, sid: str, tmp_root: Path, pid=None, cfg=None) -> dict:
"""Everything a claude session `sid` owns, as claude 2.1 lays it out
(`cfg`: a CLAUDE_CONFIG_DIR root instead of <home>/.claude)."""
cfg = cfg or h / ".claude"
key = workspace_key(str(ws))
proj = cfg / "projects" / key
proj.mkdir(parents=True, exist_ok=True)
t = {}
t["jsonl"] = proj / f"{sid}.jsonl"
t["jsonl"].write_text(json.dumps({"sessionId": sid, "cwd": os.path.realpath(str(ws))}) + "\n")
t["proj_dir"] = proj / sid
(t["proj_dir"] / "tool-results").mkdir(parents=True)
(t["proj_dir"] / "tool-results" / "a.txt").write_text("r")
(t["proj_dir"] / "subagents").mkdir()
(t["proj_dir"] / "subagents" / "agent-1.jsonl").write_text("{}\n")
t["file_history"] = cfg / "file-history" / sid
t["file_history"].mkdir(parents=True)
(t["file_history"] / "x@v1").write_text("edited")
t["session_env"] = cfg / "session-env" / sid
t["session_env"].mkdir(parents=True)
t["job"] = cfg / "jobs" / sid[:8]
t["job"].mkdir(parents=True)
(t["job"] / "state.json").write_text(json.dumps({"sessionId": sid, "backend": "daemon"}))
(t["job"] / "timeline.jsonl").write_text("{}\n")
t["session_json"], t["session_key"] = _session_file(cfg, pid or _dead_pid(), sid)
t["tmp"] = tmp_root / f"claude-{UID}" / key / sid
(t["tmp"] / "scratchpad").mkdir(parents=True)
(t["tmp"] / "tasks").mkdir()
(t["tmp"] / "tasks" / "b.output").write_text("out")
return t
def _ctx(ws: Path, h: Path) -> DiscoveryContext:
return DiscoveryContext(workspace=str(ws), agent_name="claude", home_dir=str(h))
def _masked(p: Path) -> str:
return os.path.join(str(p.parent), p.name.split(".")[0] + ".*.key")
def _env(extra=None) -> dict:
env = dict(os.environ)
for k in SESSION_ENV + ("MAM_ORCHESTRATOR_UUIDS",):
env.pop(k, None)
env["PYTHONPATH"] = f"{SKILLS}:{env.get('PYTHONPATH', '')}"
if extra:
env.update(extra)
return env
def _agents_cli(args, cwd) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, "-m", "lib_py.agents"] + list(args), capture_output=True,
text=True, env=_env(), cwd=str(cwd), timeout=60)
# ---------------------------------------------------------------------------
# claude adapter
# ---------------------------------------------------------------------------
def test_claude_purge_removes_every_owned_path(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
purged = get_adapter("claude").purge_artifacts(conv, _ctx(ws, home))
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
expected = {str(p) for k, p in t.items() if k != "session_key"} | {_masked(t["session_key"])}
assert set(purged) == expected
assert len(purged) == len(expected)
assert not any(t["session_key"].name.split(".")[1] in p for p in purged), "the key's hex is never printed"
def test_claude_purge_leaves_other_sessions_and_shared_files(home, tmp_path, conv, other, live):
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
t = _tree(home, ws, conv, tmp_path / "ctmp")
o = _tree(home, ws, other, tmp_path / "ctmp")
o_live = _session_file(cfg, live.pid, other)
history = cfg / "history.jsonl"
history.write_bytes((json.dumps({"sessionId": conv, "display": "hi"}) + "\n").encode())
before = history.read_bytes()
memory = cfg / "projects" / workspace_key(str(ws)) / "memory" / "MEMORY.md"
memory.parent.mkdir()
memory.write_text("m")
get_adapter("claude").purge_artifacts(conv, _ctx(ws, home))
assert all(p.exists() for p in o.values())
assert all(p.exists() for p in o_live)
assert history.read_bytes() == before
assert memory.exists()
def test_claude_purge_keeps_a_job_dir_of_another_session_with_the_same_prefix(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
twin = conv[:8] + str(uuidlib.uuid4())[8:]
(t["job"] / "state.json").write_text(json.dumps({"sessionId": twin}))
adapter = get_adapter("claude")
adapter.purge_artifacts(conv, _ctx(ws, home))
assert (t["job"] / "state.json").exists()
assert str(t["job"]) not in adapter.residual_artifacts(conv, _ctx(ws, home))
def test_claude_residual_lists_leftovers_then_nothing(home, tmp_path, conv, other):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
o = _tree(home, ws, other, tmp_path / "ctmp")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
before = adapter.residual_artifacts(conv, ctx)
names = {str(t[k]) for k in ("jsonl", "proj_dir", "file_history", "session_env", "job", "session_json", "tmp")}
assert names <= set(before), names - set(before)
assert not any(other in p for p in before)
assert not any(str(o["job"]) == p or str(o["session_json"]) == p for p in before)
adapter.purge_artifacts(conv, ctx)
assert adapter.residual_artifacts(conv, ctx) == []
# A transcript under another workspace key is not this purge's to remove,
# but it still carries the uuid.
stray = home / ".claude" / "projects" / "-elsewhere" / f"{conv}.jsonl"
stray.parent.mkdir()
stray.write_text("{}\n")
adapter.purge_artifacts(conv, ctx)
assert stray.exists()
assert adapter.residual_artifacts(conv, ctx) == [str(stray)]
@pytest.mark.parametrize("bad", ["", ".", "..", "*", "../x", "a/b"])
def test_claude_rejects_malformed_uuid(home, tmp_path, conv, bad):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
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 all(p.exists() for p in t.values())
def test_purge_cli_resolves_claude_from_leftovers_only(home, tmp_path, conv):
ws = tmp_path / "ws"
ws.mkdir()
fh = home / ".claude" / "file-history" / conv
se = home / ".claude" / "session-env" / conv
fh.mkdir(parents=True)
se.mkdir(parents=True)
res = _agents_cli(["purge", "auto", conv, str(ws)], ws)
assert res.returncode == 0, res.stdout + res.stderr
out = json.loads(res.stdout)
assert out["agent"] == "claude"
assert set(out["purged"]) == {str(fh), str(se)}
assert out["residual"] == []
assert not fh.exists() and not se.exists()
def test_purge_cli_refuses_a_live_claude_without_uuid_on_argv(home, tmp_path, conv, live):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid)
res = _agents_cli(["purge", "claude", conv, str(ws)], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert all(p.exists() for p in t.values())
def test_a_reused_pid_does_not_hold_the_conversation(home, tmp_path, conv, live):
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
# The file was written an hour before the process now holding that pid
# started: the pid was reused, the session is gone.
made = _session_file(cfg, live.pid, conv, started_ms=int((time.time() - 3600) * 1000))
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is False
purged = adapter.purge_artifacts(conv, ctx)
assert not any(p.exists() for p in made)
assert set(purged) == {str(made[0]), _masked(made[1])}
def test_session_file_liveness_fails_safe(monkeypatch, live):
from lib_py.agents.adapters import claude as claude_mod
now_ms = int(time.time() * 1000)
assert claude_mod._session_file_live({"startedAt": now_ms}, live.pid) is True
assert claude_mod._session_file_live({}, live.pid) is True, "no startedAt: cannot tell, so live"
assert claude_mod._session_file_live({"startedAt": now_ms}, _dead_pid()) is False
assert claude_mod._session_file_live({"startedAt": now_ms}, None) is True
assert claude_mod._session_file_live({"startedAt": now_ms}, 0) is True
assert claude_mod._session_file_live({"startedAt": 0}, live.pid) is True, "no usable startedAt: live"
hour_ago = int((time.time() - 3600) * 1000)
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is False, "reused pid"
monkeypatch.setenv("PATH", "/nonexistent")
assert claude_mod._session_file_live({"startedAt": hour_ago}, live.pid) is True, "ps unavailable: live"
def test_claude_purge_takes_the_stale_key_with_its_session_file(home, tmp_path, conv, other, live):
ws = tmp_path / "ws"
ws.mkdir()
cfg = home / ".claude"
stale = _session_file(cfg, _dead_pid(), conv)
held = _session_file(cfg, live.pid, other)
lone_key = cfg / "sessions" / f"{_dead_pid()}.{'a' * 64}.key"
lone_key.write_bytes(b"k")
purged = get_adapter("claude").purge_artifacts(conv, _ctx(ws, home))
assert not any(p.exists() for p in stale)
assert all(p.exists() for p in held)
assert lone_key.exists(), "a key with no session file of this conversation is not touched"
assert set(purged) == {str(stale[0]), _masked(stale[1])}
@pytest.mark.parametrize("project_override", [False, True])
def test_claude_config_dir_moves_the_config_root(home, tmp_path, monkeypatch, conv, live, project_override):
"""CLAUDE_CONFIG_DIR moves sessions/, file-history/, ... and projects/:
a live session there is refused, a stopped one is cleaned up. With
CLAUDE_PROJECT_DIR naming the same projects/ the result is the same."""
ws = tmp_path / "ws"
ws.mkdir()
cfg = tmp_path / "cfg"
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
if project_override:
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(cfg / "projects"))
else:
monkeypatch.delenv("CLAUDE_PROJECT_DIR")
t = _tree(home, ws, conv, tmp_path / "ctmp", pid=live.pid, cfg=cfg)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
assert adapter.conversation_in_use(conv, ctx) is True
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())
live.kill()
live.wait()
purged = 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 str(t["file_history"]) in purged and str(t["jsonl"]) in purged
assert adapter.residual_artifacts(conv, ctx) == []
assert not (home / ".claude" / "file-history").exists(), "the default root is not used"
def _failing_remove(monkeypatch, victim: Path):
from lib_py.agents.adapters import claude as claude_mod
real = os.remove
def remove(path, *a, **kw):
if os.path.realpath(path) == os.path.realpath(str(victim)):
raise PermissionError(13, "denied", str(path))
return real(path, *a, **kw)
monkeypatch.setattr(claude_mod.os, "remove", remove)
def test_a_key_that_cannot_be_removed_keeps_its_session_file(home, tmp_path, monkeypatch, conv):
ws = tmp_path / "ws"
ws.mkdir()
sfile, key = _session_file(home / ".claude", _dead_pid(), conv)
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
with monkeypatch.context() as m:
_failing_remove(m, key)
purged = adapter.purge_artifacts(conv, ctx)
assert key.exists() and sfile.exists(), "the session file is what ties the key to this session"
assert purged == []
assert adapter.residual_artifacts(conv, ctx) == sorted([str(sfile), _masked(key)])
assert set(adapter.purge_artifacts(conv, ctx)) == {_masked(key), str(sfile)}
assert adapter.residual_artifacts(conv, ctx) == []
def test_a_job_dir_that_cannot_be_emptied_keeps_its_state_json(home, tmp_path, monkeypatch, conv):
ws = tmp_path / "ws"
ws.mkdir()
t = _tree(home, ws, conv, tmp_path / "ctmp")
adapter = get_adapter("claude")
ctx = _ctx(ws, home)
with monkeypatch.context() as m:
_failing_remove(m, t["job"] / "timeline.jsonl")
purged = adapter.purge_artifacts(conv, ctx)
assert (t["job"] / "state.json").exists(), "state.json proves the dir is this session's"
assert str(t["job"]) not in purged
assert not t["file_history"].exists()
assert adapter.residual_artifacts(conv, ctx) == [str(t["job"])]
assert adapter.purge_artifacts(conv, ctx) == [str(t["job"])]
assert not t["job"].exists()
assert adapter.residual_artifacts(conv, ctx) == []
def test_claude_tmp_root_rules(tmp_path, monkeypatch):
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):
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 []
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(x))
monkeypatch.setenv("TMPDIR", str(z) + "/")
assert real(_tmp_roots()) == [str(x / name), str(z / name)] + sys_tmp
# A sandboxed tool shell: CLAUDE_CODE_TMPDIR is the per-uid root itself.
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(y / name))
assert real(_tmp_roots())[0] == str(y / name)
# Missing candidates are dropped, duplicates collapse.
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(z))
monkeypatch.delenv("TMPDIR", raising=False)
monkeypatch.delenv("TMP", raising=False)
monkeypatch.setenv("TEMP", str(z))
assert real(_tmp_roots()) == [str(z / name)] + sys_tmp
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(tmp_path / "absent"))
assert real(_tmp_roots()) == [str(z / name)] + sys_tmp
# ---------------------------------------------------------------------------
# in-use CLI and the stop gate
# ---------------------------------------------------------------------------
def test_in_use_cli_waits_then_answers(home, tmp_path, conv, live):
ws = tmp_path / "ws"
ws.mkdir()
t0 = time.monotonic()
res = _agents_cli(["in-use", "claude", conv, str(ws), "--wait", "1"], ws)
assert res.returncode == 0, res.stdout + res.stderr
assert json.loads(res.stdout) == {"agent": "claude", "in_use": False}
assert time.monotonic() - t0 < 5
_session_file(home / ".claude", live.pid, conv)
t0 = time.monotonic()
res = _agents_cli(["in-use", "claude", conv, str(ws), "--wait", "1"], ws)
assert res.returncode == 4, res.stdout + res.stderr
assert json.loads(res.stdout)["in_use"] is True
assert time.monotonic() - t0 >= 1
assert _agents_cli(["in-use", "nosuchagent", conv, str(ws)], ws).returncode == 1
assert _agents_cli(["in-use", "claude"], ws).returncode == 1
def _stop_row(sandbox: Path, name: str, sid: str):
lib = sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = sandbox / ".mam" / "agent-sessions.yaml"
mutation = (f"d['herdr_sessions'] = [{{'name': {name!r}, 'status': 'running', 'agent': 'claude', "
f"'claude_session_id_own': {sid!r}, 'pane': {{'cwd': {os.path.realpath(str(sandbox))!r}}}}}]\n")
res = subprocess.run(["bash", "-c", f"source {lib} && atomic_dump_yaml {yaml_path}"], input=mutation,
capture_output=True, text=True, env=_env())
assert res.returncode == 0, res.stdout + res.stderr
def _stop(sandbox: Path, name: str) -> subprocess.CompletedProcess:
script = sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
return subprocess.run(["bash", str(script), "--session", name, "--agent", "claude",
"--purge-conversation", "--yes"], capture_output=True, text=True,
env=_env(), cwd=str(sandbox), timeout=120)
def _row_left(sandbox: Path, name: str) -> tuple:
in_yaml = name in (sandbox / ".mam" / "agent-sessions.yaml").read_text()
conn = sqlite3.connect(str(sandbox / ".mam" / "agent-sessions.db"))
try:
in_db = conn.execute("SELECT COUNT(*) FROM sessions WHERE name=?", (name,)).fetchone()[0] > 0
finally:
conn.close()
return in_yaml, in_db
@pytest.fixture
def stop_box(mam_sandbox, mock_herdr, monkeypatch):
monkeypatch.setenv("CLAUDE_CODE_TMPDIR", str(mam_sandbox / "ctmp"))
return mam_sandbox
def test_stop_purge_reports_residual_and_removes_the_row(stop_box, conv):
name = "issue31-creator-claude"
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp")
_stop_row(stop_box, name, conv)
assert _row_left(stop_box, name) == (True, True)
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert "herdr already dead" in res.stdout
assert res.stdout.count("purged: ") == 8, res.stdout
assert "residual: none (agent: claude)" in res.stdout
assert not any(p.exists() for p in t.values())
assert _row_left(stop_box, name) == (False, False)
def test_stop_purge_warns_about_a_transcript_under_another_key(stop_box, conv):
name = "issue31-creator-claude"
_tree(stop_box, stop_box, conv, stop_box / "ctmp")
stray = stop_box / ".claude" / "projects" / "-elsewhere" / f"{conv}.jsonl"
stray.parent.mkdir()
stray.write_text("{}\n")
_stop_row(stop_box, name, conv)
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert f"WARN residual: {stray}" in res.stdout
assert "residual: none" not in res.stdout
assert _row_left(stop_box, name) == (False, False)
def test_stop_purge_refuses_while_the_claude_process_lives(stop_box, conv):
name = "issue31-creator-claude"
holder = subprocess.Popen(["sleep", "120"])
try:
time.sleep(0.05)
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp", pid=holder.pid)
_stop_row(stop_box, name, conv)
res = _stop(stop_box, name)
assert res.returncode == 4, res.stdout + res.stderr
assert "herdr already dead" in res.stdout
assert "records preserved" 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)
finally:
holder.kill()
holder.wait()
# Not a permanent block: once the process is gone the same stop purges.
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert not any(p.exists() for p in t.values())
assert _row_left(stop_box, name) == (False, False)
@pytest.mark.parametrize("project_override", [False, True])
def test_stop_purge_follows_claude_config_dir(stop_box, conv, monkeypatch, project_override):
"""stop resolves the uuid before it purges: with CLAUDE_CONFIG_DIR alone
the transcript under <cfg>/projects must be found too, or the row goes
and every artifact stays ("nothing purged")."""
cfg = stop_box / "custom-config"
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
if project_override:
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(cfg / "projects"))
else:
monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
name = "issue31-creator-claude"
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp", cfg=cfg)
_stop_row(stop_box, name, conv)
res = _stop(stop_box, name)
assert res.returncode == 0, res.stdout + res.stderr
assert "nothing purged" not in res.stdout
assert "residual: none (agent: claude)" in res.stdout
assert not any(p.exists() for p in t.values()), {k: str(p) for k, p in t.items() if p.exists()}
assert _row_left(stop_box, name) == (False, False)
def test_discovery_context_projects_follow_claude_config_dir(tmp_path, monkeypatch):
from lib_py.agents.base import DiscoveryContext as Ctx
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
assert Ctx(str(tmp_path), "claude", home_dir=str(tmp_path / "h")).claude_dir == str(tmp_path / "cfg" / "projects")
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / "p"))
assert Ctx(str(tmp_path), "claude", home_dir=str(tmp_path / "h")).claude_dir == str(tmp_path / "p")
# ---------------------------------------------------------------------------
# orc_onboard --remove --purge-conversation
# ---------------------------------------------------------------------------
def _run_onboard(args, sandbox: Path) -> subprocess.CompletedProcess:
env = _env({
"AGENT_SESSIONS_YAML": str(sandbox / ".mam" / "agent-sessions.yaml"),
"YAML_PATH": str(sandbox / ".mam" / "agent-sessions.yaml"),
"HOME_DIR": str(sandbox),
"AGENT_PYTHON_BIN": sys.executable,
"DELEGATE_JOB_PYTHON": sys.executable,
})
return subprocess.run(["bash", str(ONBOARD_SH)] + list(args), capture_output=True, text=True,
env=env, cwd=str(sandbox), timeout=120)
@pytest.mark.parametrize("stray", [False, True])
def test_orc_remove_purge_reports_claude_leftovers(stop_box, conv, stray):
t = _tree(stop_box, stop_box, conv, stop_box / "ctmp")
other_key = stop_box / ".claude" / "projects" / "-elsewhere" / f"{conv}.jsonl"
if stray:
other_key.parent.mkdir()
other_key.write_text("{}\n")
assert _run_onboard(["--uuid", conv], stop_box).returncode == 0
res = _run_onboard(["--remove", conv, "--purge-conversation", "--yes", "--agent", "claude"], stop_box)
assert res.returncode == 0, res.stdout + res.stderr
assert not any(p.exists() for p in t.values())
if stray:
assert f"WARN residual: {other_key}" in res.stdout
else:
assert "residual: none (agent: claude)" in res.stdout
# ---------------------------------------------------------------------------
# codex, grok, opencode: residual only, purge unchanged
# ---------------------------------------------------------------------------
def _db(path: Path, ddl: list, rows: list):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
for stmt in ddl:
conn.execute(stmt)
for stmt, args in rows:
conn.execute(stmt, args)
conn.commit()
conn.close()
def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch):
u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}"
db = home / ".local" / "share" / "opencode" / "opencode.db"
ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)",
"CREATE TABLE message (id TEXT, session_id TEXT)",
"CREATE TABLE part (id TEXT, session_id TEXT)",
"CREATE TABLE event (id TEXT, aggregate_id TEXT)",
"CREATE TABLE event_sequence (aggregate_id TEXT, seq INTEGER)",
"CREATE TABLE todo (id TEXT, session_id TEXT)"]
rows = []
for s in (u, o):
rows += [("INSERT INTO session VALUES (?)", (s,)),
("INSERT INTO message VALUES ('m', ?)", (s,)),
("INSERT INTO part VALUES ('p', ?)", (s,)),
("INSERT INTO event VALUES ('e1', ?)", (s,)),
("INSERT INTO event VALUES ('e2', ?)", (s,)),
("INSERT INTO event_sequence VALUES (?, 2)", (s,)),
("INSERT INTO todo VALUES ('t', ?)", (s,))]
_db(db, ddl, rows)
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_resolve_db", lambda ctx: str(db)) # never run a real opencode
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(home))
adapter.purge_artifacts(u, ctx)
assert adapter.residual_artifacts(u, ctx) == [f"{db} (event.aggregate_id: 2 rows)",
f"{db} (event_sequence.aggregate_id: 1 rows)",
f"{db} (todo.session_id: 1 rows)"]
def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other):
codex_home = tmp_path / "codex"
monkeypatch.setenv("CODEX_HOME", str(codex_home))
rollout = codex_home / "sessions" / "2026" / "10" / "15" / f"rollout-2026-10-15T00-00-00-{conv}.jsonl"
rollout.parent.mkdir(parents=True)
rollout.write_text("{}\n")
marker = codex_home / "tui-thread-reference-capabilities" / conv
marker.parent.mkdir()
marker.write_text("")
(codex_home / "tui-thread-reference-capabilities" / other).write_text("")
state = codex_home / "state_5.sqlite"
_db(state, ["CREATE TABLE threads (id TEXT PRIMARY KEY)"],
[("INSERT INTO threads VALUES (?)", (conv,)), ("INSERT INTO threads VALUES (?)", (other,))])
hist = codex_home / "thread_history_1.sqlite"
_db(hist, ["CREATE TABLE thread_items (thread_id TEXT, n INTEGER)"],
[("INSERT INTO thread_items VALUES (?, 1)", (conv,)), ("INSERT INTO thread_items VALUES (?, 2)", (conv,))])
ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="codex", home_dir=str(home))
# purge is not run: it would execute the real `codex delete` on PATH.
residual = get_adapter("codex").residual_artifacts(conv, ctx)
assert residual == sorted([str(marker), str(rollout)]) + [
f"{state} (threads.id: 1 rows)", f"{hist} (thread_items.thread_id: 2 rows)"]
def test_grok_residual_reports_memory_observations(home, tmp_path, conv, other):
from urllib.parse import quote
ws = tmp_path / "ws"
ws.mkdir()
grok = home / ".grok"
sess = grok / "sessions" / quote(os.path.realpath(str(ws)), safe="") / conv
sess.mkdir(parents=True)
(sess / "chat_history.jsonl").write_text(json.dumps({"session_id": conv}) + "\n")
inbox = grok / "memory-v2" / "workspaces" / "w" / "observations" / "_inbox"
inbox.mkdir(parents=True)
mine = inbox / f"{conv}__t1.md"
mine.write_text("obs")
(inbox / f"{other}__t1.md").write_text("obs")
adapter = get_adapter("grok")
ctx = DiscoveryContext(workspace=str(ws), agent_name="grok", home_dir=str(home))
adapter.purge_artifacts(conv, ctx)
assert not sess.exists()
assert adapter.residual_artifacts(conv, ctx) == [str(mine)]