diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py index 2dac388..760eb35 100644 --- a/.agents/skills/lib_py/agents/__main__.py +++ b/.agents/skills/lib_py/agents/__main__.py @@ -155,7 +155,8 @@ def main(): # in-use [workspace] [--wait ] (Issue #31) # The purge CLI's own in-use verdict, for stop_session.sh to ask # before it purges. Re-checks every 0.25 s for up to --wait seconds. - # Prints {"agent", "in_use"}. Exit 0 free; 4 still in use; 1 bad args. + # Prints {"agent", "in_use"}, and while in use where to look on + # stderr. Exit 0 free; 4 still in use; 1 bad args. args = sys.argv[2:] wait = 0.0 if '--wait' in args: @@ -181,6 +182,10 @@ def main(): time.sleep(0.25) in_use = adapter.conversation_in_use(uuid, ctx) print(json.dumps({"agent": agent_name, "in_use": in_use})) + if in_use: + print(" What may hold it:", file=sys.stderr) + for hint in adapter.in_use_hints(uuid, ctx): + print(f" {hint}", file=sys.stderr) sys.exit(4 if in_use else 0) else: diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index af29fcb..50e092d 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -345,6 +345,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter): return True return any(_session_file_live(data, pid) for _, pid, data in self._session_files(uuid, 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(uuid, ctx) + def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list: # Best effort: never raises (stop runs this inside its registry # transaction). What could not be removed is not listed here and diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index 57d4f14..cd27ee4 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -308,6 +308,11 @@ class BaseAgentAdapter: """True while a live process still references the conversation.""" return conversation_in_use(uuid, [self.artifact_path(uuid, ctx)]) + def in_use_hints(self, uuid: str, ctx: DiscoveryContext) -> list: + """Where to look for what holds the conversation, shown when a purge + is refused because it is in use.""" + return [f"any process with it on its command line (an editor, a grep, ...): ps -axww | grep {uuid}"] + def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str: raise NotImplementedError diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 0580685..6bb4c33 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -62,7 +62,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \ | 1 | From the checks before the stop, nothing was changed: YAML not found, or the session is not registered. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable | | 3 | `--purge-conversation` without `--yes` | -| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain (nothing was changed) | +| 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down | | other | The registry update after the stop (YAML / DB write) failed and its code is passed through | Exits 2 and 3, and exit 1 from the pre-stop checks, come before anything is stopped, so nothing was changed. On exit 1 from the registry update, or an `other` exit, the session may already be down while its row still says `running`: check with `multi-agent-mux-status`, then run the stop again (it is idempotent). diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 1d98630..d315126 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -289,12 +289,12 @@ fi # registry lock. Any answer but "free" (0) keeps everything. if [ "$PURGE" = "1" ] && [ -n "$PURGE_UUID" ]; then inuse_rc=0 - "$(_delegate_py_bin)" -m lib_py.agents in-use "$AGENT" "$PURGE_UUID" "$TARGET_CWD" --wait 5 >/dev/null || inuse_rc=$? + inuse_hints="$("$(_delegate_py_bin)" -m lib_py.agents in-use "$AGENT" "$PURGE_UUID" "$TARGET_CWD" --wait 5 2>&1 >/dev/null)" || inuse_rc=$? if [ "$inuse_rc" != "0" ]; then echo "ERROR: conversation $PURGE_UUID is still held by a live agent process (rc=$inuse_rc)." >&2 - echo " Refusing purge — records preserved (no state was modified)." >&2 - echo " For claude, see the session files naming it under ~/.claude/sessions/." >&2 - delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" + echo " Refusing purge — records preserved: the registry row and the conversation files are untouched (the session itself may already be down)." >&2 + [ -n "$inuse_hints" ] && printf '%s\n' "$inuse_hints" >&2 + delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: conversation still in use; records preserved" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" exit 4 fi fi diff --git a/tests/test_issue31_purge_residual.py b/tests/test_issue31_purge_residual.py index 945be49..5e48604 100644 --- a/tests/test_issue31_purge_residual.py +++ b/tests/test_issue31_purge_residual.py @@ -533,6 +533,7 @@ def test_claude_config_dir_moves_the_config_root(home, tmp_path, monkeypatch, co ctx = _ctx(ws, home) assert adapter.conversation_in_use(conv, ctx) is True + assert adapter.in_use_hints(conv, ctx)[0] == f"a claude process whose session file under {cfg / 'sessions'}/ names it" res = _agents_cli(["purge", "claude", conv, str(ws)], ws) assert res.returncode == 4, res.stdout + res.stderr assert all(p.exists() for p in t.values()) @@ -649,6 +650,8 @@ def test_in_use_cli_waits_then_answers(home, tmp_path, conv, live): assert res.returncode == 4, res.stdout + res.stderr assert json.loads(res.stdout)["in_use"] is True assert time.monotonic() - t0 >= 1 + assert f"session file under {home / '.claude' / 'sessions'}/ names it" in res.stderr + assert f"ps -axww | grep {conv}" in res.stderr assert _agents_cli(["in-use", "nosuchagent", conv, str(ws)], ws).returncode == 1 assert _agents_cli(["in-use", "claude"], ws).returncode == 1 @@ -732,6 +735,8 @@ def test_stop_purge_refuses_while_the_claude_process_lives(stop_box, conv): assert res.returncode == 4, res.stdout + res.stderr assert "herdr already dead" in res.stdout assert "records preserved" in res.stderr + assert f"session file under {stop_box / '.claude' / 'sessions'}/ names it" in res.stderr + assert f"ps -axww | grep {conv}" in res.stderr assert "purged: " not in res.stdout assert all(p.exists() for p in t.values()) assert _row_left(stop_box, name) == (True, True)