Review loop (--review-only, --all-reviewer, focused on this Mac: macOS, /usr/bin/python3 3.9.6, bash 3.2): round 1 (@6f4f40a) agy and grok PASS, claude and codex NOT PASS (a non-uuid id `memory` purged the auto-memory dir, a linked dir below a root was followed, a NaN startedAt counted as dead), sent as a PR comment and fixed by the author in eleven commits; round 2 (@2a8d738) confirmed all three fixed but 4/4 NOT PASS on one test holder (`sh -c "sleep 120" <uuid>` loses the uuid on macOS /bin/sh), fixed in de6f3fc; round 3 (@de6f3fc) 4/4 PASS. Full suite on macOS / Python 3.9.6 @de6f3fc: 1934 passed, 56 skipped, 1 error (the known #55 false positive: agy conversations of another workspace). The real ~/.claude, /private/tmp/claude-<uid> and herdr panes unchanged. Also names lib_py.delegation.logroot in eager.CORE_MODULES (the v4.9.4 review's non-blocking note). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
48 lines
8.8 KiB
Markdown
48 lines
8.8 KiB
Markdown
# Review f0df1be0 — PR #59, Issue #31 claude purge
|
||
|
||
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261016T003524Z-a51548/1` (`loop-20261016T003524Z-a51548`). Head `6f4f40a4adb2115d5997dffd31fb6473f05cab68`, tree `f4d9422ad0c1d616b5f37065f3e96b34c28f78d8`, base `26e2c43753169671b600d03274901dbe203988dd` is an ancestor. Thirteen commits, 12 files, +1127/−27. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is still `26e2c43753169671b600d03274901dbe203988dd`, porcelain empty. No product file was edited.
|
||
|
||
Host tools: `/usr/bin/python3` 3.9.6, the control-plane venv the same 3.9.6, `/bin/bash` 3.2.57. `bash -n` accepts `stop_session.sh` and `lib.sh`. With `set -u`, the new `CLAUDE_PROJECT_DIR` default (`lib.sh:78`) expands to `$HOME/.claude/projects` when both variables are unset, and to `$CLAUDE_CONFIG_DIR/projects` when only the config dir is set.
|
||
|
||
Every purge, `in-use`, and `stop --purge-conversation` exercised here used a temporary `HOME`, `CLAUDE_CONFIG_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_TMPDIR`, and `TMPDIR`. The real `~/.claude` and `/private/tmp/claude-<uid>` were listed with `ls`-level reads.
|
||
|
||
## What this Mac already stores
|
||
|
||
`~/.claude` has real directories `file-history`, `session-env`, `sessions`, `jobs`, and `projects`. `file-history` holds 18 uuid-named directories and no symlinks. `session-env` holds 21 uuid-named directories and no symlinks. `sessions` holds 5 `*.json` files and 5 key files; every key name matches `<pid>.<64 lowercase hex>.key`. Each json has integer `pid`, `sessionId`, and a millisecond `startedAt` (13 digits). The filename stem equals `pid` in all 5. All 5 pids were alive at the read. `jobs` holds 2 real directories, both 8 characters long, and each `state.json` `sessionId` starts with that directory name. No job entry is a symlink. `projects` has 16 keys, none of them symlinks; across those keys the entries include 25 `<uuid>.jsonl` files and 7 `<uuid>` directories, plus shared names such as the memory directory the purge leaves alone.
|
||
|
||
`/tmp` is a symlink to `/private/tmp`. `/tmp/claude-<uid>` and `/private/tmp/claude-<uid>` both exist and share one real path. Under that root, project keys are real directories. Their children are uuid directories (16) plus 3 other directories whose names are 57 or 58 alphanumeric characters, contain no uuid, and are siblings of the uuid scratchpads. `$TMPDIR/claude-<uid>` does not exist. `CLAUDE_CODE_TMPDIR` was unset in this review process. That is the layout `_tmp_roots` (`claude.py:25-48`) and `_owned_paths` (`claude.py:256-285`) assume: an existing per-uid root, then `<key>/<uuid>`.
|
||
|
||
## lstart on this host
|
||
|
||
`ps -p <pid> -o lstart=` with `LC_ALL=C` prints five fields. `time.strptime` of the collapsed line with `%a %b %d %H:%M:%S %Y` accepts both `Oct 5` and the BSD space-padded `Oct 5`. For the five live session files, `lstart - startedAt/1000` is between −2.43 s and −0.694 s, so `lstart <= startedAt/1000 + 2` (`claude.py:77`) holds for every one of them. Zero of those live files would be treated as a reused pid.
|
||
|
||
A temp-home probe of `_session_file_live`: the current pid with `startedAt` set to now is live; the same pid with `startedAt` 1.5 s after its own `lstart` is live; missing `startedAt`, `startedAt` 0, a non-numeric `startedAt`, `pid` 0, a negative pid, and boolean `True` are live. A dead pid is stale. The same live pid with `startedAt` an hour earlier, or 3 s earlier than its `lstart`, is stale. The process locale reported `en_US` / `UTF-8`, and the English month from `LC_ALL=C` parsed. A parse failure returns live (`claude.py:71-72`), which refuses the purge.
|
||
|
||
## Deletion stays on this session
|
||
|
||
On a temporary config root the purge removed the workspace `<uuid>.jsonl`, the sibling `<uuid>/` directory, `file-history/<uuid>/`, `session-env/<uuid>/`, `jobs/<uuid[:8]>/` whose `state.json` named the session, the dead `sessions/<pid>.json`, its `<pid>.<64 hex>.key`, and `<CLAUDE_CODE_TMPDIR>/claude-<uid>/<key>/<uuid>/`. The printed key label contains `<pid>.*.key` and the hex is absent from the purged list.
|
||
|
||
These stayed: another session's `sessions/<pid>.json` and key for a live pid, that session's scratchpad, `history.jsonl` whose body names the purged uuid, `projects/<key>/memory/MEMORY.md`, and a job directory whose 8-character name matches the uuid but whose `state.json` names a different session (`claude.py:244-254`). `_plausible_id` (`base.py:89-92`) rejects `""`, `.`, `..`, `*`, `../x`, `a/b`, and `....` (`".."` is a substring). Purging those ids left the tree unchanged.
|
||
|
||
Symlinks. A `file-history/<uuid>` symlink to an outside directory was unlinked; the outside `secret.txt` remained. A `projects/<key>/<uuid>` symlink to an outside directory was unlinked; the outside file remained. A directory symlink and a file symlink inside a real owned `<uuid>/` directory were removed with the directory; both targets remained (`_remove`, `claude.py:80-87`, uses `os.path.islink` and then `os.remove`; `shutil.rmtree` sees the real directory). A `jobs/<8>` symlink whose target `state.json` names the session was unlinked; the target directory and its `other.txt` remained (`_remove_job`, `claude.py:107-109`). A `sessions/<pid>.json` symlink to an outside json was unlinked after the read saw this session id; the outside file remained.
|
||
|
||
A project key under the temp root that is itself a symlink is followed: `<symlink>/<uuid>` was removed on the far side. That far-side directory is still the one named this session's uuid, which is the scratchpad path when a key has been relocated. The real per-uid temp root on this Mac has no key symlinks. The cross-session paths above stayed in the same probe.
|
||
|
||
A live session file is omitted from `_owned_paths` (`claude.py:274-278`). Calling `purge_artifacts` directly while that pid was alive left `sessions/<pid>.json` in place and removed `file-history/<uuid>/`. After the process exited, the same call removed the session file. The purge CLI does not take that direct path: `in-use` was true, the process exited 4, and the file-history marker was still there (`__main__.py:146-148`). `in-use` with no holder exits 0. `in-use` without a uuid exits 1.
|
||
|
||
## Stop
|
||
|
||
`stop_session.sh:285-300` runs `python -m lib_py.agents in-use <agent> <uuid> <cwd> --wait 5` before the registry write. Any status other than 0 exits 4 and leaves the row. The registry python then prints `purged:` lines and either `WARN residual:` or `residual: none (agent: <agent>)` (`stop_session.sh:355-368`). `lib.sh` exports `PYTHONPATH` to the skill dir at source time, so the new `in-use` module resolves the same way `exit-key` already does. `DELEGATE_JOB_ID`, `DELEGATE_JOB_TOKEN`, and `DELEGATE_JOB_ATTEMPT` are set before that call (`stop_session.sh:146-161`), including to empty strings, so `set -u` does not trip. The passthrough `raise SystemExit(1)` is now `stop_session.sh:327`, and `tests/data/skill_code_blocks.json` records that line. The stop SKILL.md diagram and the purge paragraph match the fenced blocks in that json (the skill-block test passed).
|
||
|
||
## Tests
|
||
|
||
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamf0-f0df1be0-work/pybase`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CODEX_HOME`, and `PYTHONPATH` were dropped. The suite's own fixtures then point `HOME` at the test tmp.
|
||
|
||
On the snapshot, 80 tests passed and 1 skipped in 28.31 s: all of `tests/test_issue31_purge_residual.py` (32 tests), `tests/test_issue42_skill_blocks.py`, and `tests/test_issue55_agent_homes.py`. The skip is `test_issue42_skill_blocks.py:266`, because `.claude/skills` is absent in this worktree. The issue-31 file covers the owned-path purge, the untouched neighbor files, the same-prefix job, malformed ids, the live-process refusal, the reused pid, a key that cannot be unlinked, a job directory that cannot be emptied, `CLAUDE_CONFIG_DIR`, the temp-root rules, the stop exit 4, the stray-key `WARN residual:`, and codex, grok, and opencode residuals.
|
||
|
||
The same issue-31 file copied onto an archive of base `26e2c43` failed 24 tests and passed 8 in 17.09 s. The failures are behavioral. The old purge left `file-history/<uuid>` and `session-env/<uuid>` behind. The old purge CLI, asked about a live pid whose argv does not contain the uuid, removed the jsonl and returned `in_use` false. The old stop did not exit 4 for that live process and did not print `WARN residual:` for a transcript under another project key. Two failures are the new helpers `_session_file_live` and `_tmp_roots` being absent; the assertions above fail on the old behavior itself.
|
||
|
||
After the report, snapshot porcelain is still empty, the snapshot still has no `.mam`, and control-plane porcelain is still empty at `26e2c43`. The herdr config mtime is still 1784456394.
|
||
|
||
[VERDICT: PASS]
|