fix(issue-31): a claude purge removes every path the session owns, reports what is left, and waits for the process; the stop docs say a purge removes the row
#59
Merged
Godopumerged 26 commits from fix/issue-31 into main2026-10-17 16:25:21 +00:00
A claude --purge-conversation now removes every path the session owns, reports whatever still carries the uuid, and refuses while a claude process still holds the conversation. The stop docs now say what the code does: a purge removes the registry row. No version bump; the version is set at merge.
One function in the claude adapter, _owned_paths, lists the session's paths. Purge, has_artifacts and the residual scan all use it.
It removes each path only if it exists:
projects/<key>/<uuid>.jsonl and projects/<key>/<uuid>/ (tool results);
file-history/<uuid>/ and session-env/<uuid>/;
jobs/<uuid[:8]>/, only when its state.json names this session;
a dead process's sessions/<pid>.json and that pid's <pid>.<hex>.key. The key is matched by name and never read, and the output masks its hex;
<tmp>/claude-<uid>/<key>/<uuid>/ (the scratchpad). The tmp roots follow CLAUDE_CODE_TMPDIR, TMPDIR/TMP/TEMP and /tmp, as Claude Code does.
Shared files (history.jsonl, ~/.claude.json, backups, paste-cache) are not rewritten.
An id that is not a canonical uuid purges nothing (since the Mac fixes; before them only empty, ., .., * and anything with / were refused).
The evidence that ties a path to the session is removed last: the job dir's state.json and the session file after its key. If a removal fails partway, the leftovers are still reported (review P2, f542596).
The claude adapter now has residual_artifacts, under the same contract as agy's.
It walks the config root by name, skipping projects, and looks two levels deep in projects and in the tmp roots.
Before this, the base default always returned [], so a purge printed residual: none whatever was left. That was the issue's symptom.
stop_session.sh now asks for the residual after the purge and prints it as orc_onboard does: WARN residual: <path> for each one, or residual: none (agent: claude). Before this, stop never asked.
codex, grok and opencode now report their residual too. Their purge scope is unchanged. What they keep (opencode event rows, grok memory observations, codex thread rows) is now printed instead of hidden.
hermes is unchanged: it has no sessions on this host, so its layout could not be checked.
A live claude does not always carry its uuid on its command line (claude -c). Before this, a purge from another shell was not refused. Claude's in-use check now also reads sessions/<pid>.json.
A pid counts as the same process only if it is alive and started no later than the file's startedAt + 2 s (ps -o lstart= under LC_ALL=C). So a reused pid is not mistaken for the session.
Anything that cannot be decided counts as live, so the purge is refused.
New CLI python -m lib_py.agents in-use <agent> <uuid> [workspace] [--wait <sec>]. It uses the same conversation_in_use as the purge CLI, so the two cannot disagree.
Before deleting, stop_session.sh --purge-conversation now calls it with --wait 5. It does so even when the herdr session is already gone, since the session ending and the process ending are not the same event.
If the conversation is still held, or the check fails for any reason, stop exits 4 and changes nothing (exit 4 already meant "purge aborted").
The claude config root is CLAUDE_CONFIG_DIR, else <home>/.claude. The purge, liveness and residual paths all use it.
The projects root follows it as well, in lib.sh and in DiscoveryContext, so stop finds the transcript before purging. An explicit CLAUDE_PROJECT_DIR still wins.
tests/_agent_home_guard.py drops CLAUDE_CONFIG_DIR for every test, like the other agent-home variables. test_issue55 requires every variable the adapters read to be listed there.
Issue comment §2: the stop SKILL.md said a purge leaves a terminated row, but stop_session.sh removes the row from the YAML and the DB. The docs now say the row is removed.
This covers the workflow comment, the state diagram, the purge paragraph and the target list.
The exit-4 row and the monitor note were also fixed; the monitor never marks a stopped row terminated.
Each line was changed in place, so the line numbers in tests/data/skill_code_blocks.json hold. The inventory changes are the two edited blocks' sha values and one passthrough line reference (stop_session.sh:310 → :327).
Tests
New file: tests/test_issue31_purge_residual.py (32 tests).
Against the pre-fix tree (26e2c43): 24 fail and 8 pass. The 8 that pass check what must survive a purge: other sessions and shared files, a job dir of another session with the same prefix, and malformed uuids (6 cases).
Existing tests changed:
tests/_agent_home_guard.py (one variable added);
tests/data/skill_code_blocks.json (above);
tests/test_a4_adapter_contract.py: the composite purge names its claude session by a real uuid instead of 'uuid-c' (2a8d738, approved after the Mac review).
Fixes from the Mac verification (6f4f40a..2a8d738)
The Mac review (PR comment) found three purge-safety bugs. All three were reproduced on Linux in a temp home before the fix and are blocked at 2a8d738.
[blocking] purge claude memory removed projects/<key>/memory/ (bd09c6a, 81b6c2e, c0248b8).
claude now takes only a canonical uuid (8-4-4-4-12 hex). Any other id reaches no path at all: purge, has_artifacts, residual and in-use all return nothing for it.
An uppercase or mixed-case uuid is lowercased first, so its in-use check matches the live session's lowercase id (APFS is case-insensitive by default).
The shared _plausible_id is unchanged, so other adapters' ids (ses_...) still work.
[P1] a linked parent let rmtree delete outside the owned roots (6e0361c, dd292c1).
Each candidate keeps the root it was found under (config, projects or tmp). It is removed only when every component between that root and the leaf is a real directory and the parent resolves inside the resolved root.
A root may itself be a link (/tmp -> /private/tmp). A leaf link is still removed as the link alone.
A candidate behind a link is not removed; it is reported as residual. A purge whose only leftover lies behind a link now exits 0 and lists it, instead of exiting 5 with nothing.
[P2] startedAt NaN made a live holder look stale (f943b0d, 60779ba).
startedAt must be an int or float (not bool), finite and positive. Anything else, including an integer too large for a float, counts as live.
Non-blocking notes from the comment:
lstart is compared in UTC: ps runs with TZ=UTC0 and calendar.timegm reads it, so the hour a DST change repeats can no longer shift it (af8ed39). No /proc.
The stop exit-4 refusal now names where the holder may be: claude's real sessions/ dir (following CLAUDE_CONFIG_DIR) and ps -axww | grep <uuid> for any process naming the uuid. It says "records preserved", and so does the SKILL.md exit-4 row, which is not a code block, so no inventory hash moves (e156179).
The new tests point every tmp-root candidate, including the fixed /tmp fallback, at the test's own dirs, in the test process and in every python a test starts (26fb1c4).
agy: stop's in-use gate now applies to agy too. Where another process keeps agy's DB or lock open after the CLI exits, a purge that used to run now exits 4. That is the intended fail-closed behaviour; it was not tried with a real agy.
Verification:
Tests: the new tests run on 26fb1c4 (the test-isolation hook only, before the three fixes) give 29 failed / 71 passed; on 2a8d738, 100 passed (test_issue31_purge_residual.py + test_a4_adapter_contract.py).
Review: the fix loop's third round left one item, the test_a4 fixture; the user approved that edit (2a8d738). Then a review-only loop (--base 6f4f40a --all-reviewer): claude, agy, grok and codex all PASS 2a8d738 in round 1.
Real-claude trial at 2a8d738: while live, in-use and purge both exited 4 (with the new hints) and removed nothing. stop --purge-conversation --yes exited 0, purged the jsonl, file-history/<uuid>, session-env/<uuid> and the tmp scratchpad, printed residual: none (agent: claude), and removed the row. Nothing carrying the uuid was left; the real control .mam/delegate_job_logs was unchanged.
Full suite (Linux) at 2a8d738: 1958 passed, 20 skipped, 12 failed. The 12 are the known tests/test_issue10_13_purge.py failures (#28); their messages match 6f4f40a apart from a random uuid the test generates. Nothing under ~/.claude (projects, file-history, session-env, sessions, jobs) or /tmp/claude-1000 appeared or disappeared, and the real .mam/delegate_job_logs was unchanged.
Review
/multi-agent-mux-loop --plan --all-reviewer --max-loop 3. The plan (rev 2) took the creator's challenge: the stop process gate and the pid-reuse check.
Round 1:
grok: NOT PASS. The doc blocks and the inventory had not been committed.
codex: NOT PASS.
P1: CLAUDE_CONFIG_DIR was ignored, so a live session under it could be purged.
P2: a partial failure could hide leftovers.
Round 2: codex NOT PASS. P2: stop's uuid lookup did not follow CLAUDE_CONFIG_DIR.
Round 3: claude, agy, grok and codex all returned PASS on 6f4f40a.
Follow-ups noted by reviewers, not in this PR:
the #55 test guard still watches CLAUDE_PROJECT_DIR or <home>/.claude/projects, not CLAUDE_CONFIG_DIR;
an in-use check for opencode;
what codex delete, opencode session delete and hermes sessions delete actually remove.
Verification by the orchestrator, at 6f4f40a (before the Mac fixes)
Full suite (Linux): 1923 passed, 20 skipped, 12 failed. The 12 are the known tests/test_issue10_13_purge.py failures (#28). Their messages are identical on 26e2c43 and 6f4f40a, so this branch neither adds nor changes a failure in that file.
Run with the agent-pane environment (CLAUDE_PROJECT_DIR set), and with MAM_JOBS_DIR and DELEGATE_JOB_LOGS_DIR unset.
~/.claude/projects and the real .mam/delegate_job_logs were the same before and after.
Real-claude trial: a real claude on a throwaway herdr session wrote and edited a file.
While it ran, in-use and purge both exited 4 and removed nothing.
stop --purge-conversation --yes then exited 0. It purged the jsonl, file-history/<uuid>, session-env/<uuid> and the tmp scratchpad, and printed residual: none (agent: claude). The row was gone from the YAML and the DB.
Afterwards nothing under ~/.claude or /tmp/claude-1000 carried the uuid, by name or inside sessions/*.json.
The real control .mam/delegate_job_logs was unchanged.
Not covered: Claude Code also makes a scratchpad under a second, unrelated uuid at start and resume (seen in the trial). It has no link to the conversation on disk, so it is left alone (plan §10).
Not run here: macOS /usr/bin/python3 3.9.6 and bash 3.2. The changed Python parses with the 3.9 grammar; bash -n passes.
Closes #31
A claude `--purge-conversation` now removes every path the session owns, reports whatever still carries the uuid, and refuses while a claude process still holds the conversation. The stop docs now say what the code does: a purge removes the registry row. No version bump; the version is set at merge.
## What a claude purge removes (9ccd3c1, f542596)
- One function in the claude adapter, `_owned_paths`, lists the session's paths. Purge, `has_artifacts` and the residual scan all use it.
- It removes each path only if it exists:
- `projects/<key>/<uuid>.jsonl` and `projects/<key>/<uuid>/` (tool results);
- `file-history/<uuid>/` and `session-env/<uuid>/`;
- `jobs/<uuid[:8]>/`, only when its `state.json` names this session;
- a dead process's `sessions/<pid>.json` and that pid's `<pid>.<hex>.key`. The key is matched by name and never read, and the output masks its hex;
- `<tmp>/claude-<uid>/<key>/<uuid>/` (the scratchpad). The tmp roots follow `CLAUDE_CODE_TMPDIR`, `TMPDIR`/`TMP`/`TEMP` and `/tmp`, as Claude Code does.
- Shared files (`history.jsonl`, `~/.claude.json`, backups, paste-cache) are not rewritten.
- An id that is not a canonical uuid purges nothing (since the Mac fixes; before them only empty, `.`, `..`, `*` and anything with `/` were refused).
- The evidence that ties a path to the session is removed last: the job dir's `state.json` and the session file after its key. If a removal fails partway, the leftovers are still reported (review P2, f542596).
## What it reports (366da3b, 48cda51, b346137)
- The claude adapter now has `residual_artifacts`, under the same contract as agy's.
- It walks the config root by name, skipping `projects`, and looks two levels deep in `projects` and in the tmp roots.
- Before this, the base default always returned `[]`, so a purge printed `residual: none` whatever was left. That was the issue's symptom.
- `stop_session.sh` now asks for the residual after the purge and prints it as `orc_onboard` does: `WARN residual: <path>` for each one, or `residual: none (agent: claude)`. Before this, stop never asked.
- codex, grok and opencode now report their residual too. Their purge scope is unchanged. What they keep (opencode `event` rows, grok memory observations, codex thread rows) is now printed instead of hidden.
- hermes is unchanged: it has no sessions on this host, so its layout could not be checked.
## A purge waits for the claude process (ae98cf1)
- A live claude does not always carry its uuid on its command line (`claude -c`). Before this, a purge from another shell was not refused. Claude's in-use check now also reads `sessions/<pid>.json`.
- A pid counts as the same process only if it is alive and started no later than the file's `startedAt` + 2 s (`ps -o lstart=` under `LC_ALL=C`). So a reused pid is not mistaken for the session.
- Anything that cannot be decided counts as live, so the purge is refused.
- New CLI `python -m lib_py.agents in-use <agent> <uuid> [workspace] [--wait <sec>]`. It uses the same `conversation_in_use` as the purge CLI, so the two cannot disagree.
- Before deleting, `stop_session.sh --purge-conversation` now calls it with `--wait 5`. It does so even when the herdr session is already gone, since the session ending and the process ending are not the same event.
- If the conversation is still held, or the check fails for any reason, stop exits 4 and changes nothing (exit 4 already meant "purge aborted").
- No new exit code.
## `CLAUDE_CONFIG_DIR` (8a4f651, f0af989)
- The claude config root is `CLAUDE_CONFIG_DIR`, else `<home>/.claude`. The purge, liveness and residual paths all use it.
- The projects root follows it as well, in `lib.sh` and in `DiscoveryContext`, so stop finds the transcript before purging. An explicit `CLAUDE_PROJECT_DIR` still wins.
- `tests/_agent_home_guard.py` drops `CLAUDE_CONFIG_DIR` for every test, like the other agent-home variables. `test_issue55` requires every variable the adapters read to be listed there.
## Docs: a purge removes the row (6328e67, 57830cf)
- Issue comment §2: the stop SKILL.md said a purge leaves a `terminated` row, but `stop_session.sh` removes the row from the YAML and the DB. The docs now say the row is removed.
- This covers the workflow comment, the state diagram, the purge paragraph and the target list.
- The exit-4 row and the monitor note were also fixed; the monitor never marks a `stopped` row `terminated`.
- Each line was changed in place, so the line numbers in `tests/data/skill_code_blocks.json` hold. The inventory changes are the two edited blocks' `sha` values and one passthrough line reference (`stop_session.sh:310` → `:327`).
## Tests
- New file: `tests/test_issue31_purge_residual.py` (32 tests).
- **Against the pre-fix tree (26e2c43):** 24 fail and 8 pass. The 8 that pass check what must survive a purge: other sessions and shared files, a job dir of another session with the same prefix, and malformed uuids (6 cases).
- Existing tests changed:
- `tests/_agent_home_guard.py` (one variable added);
- `tests/data/skill_code_blocks.json` (above);
- `tests/test_a4_adapter_contract.py`: the composite purge names its claude session by a real uuid instead of `'uuid-c'` (2a8d738, approved after the Mac review).
## Fixes from the Mac verification (6f4f40a..2a8d738)
The Mac review (PR comment) found three purge-safety bugs. All three were reproduced on Linux in a temp home before the fix and are blocked at `2a8d738`.
1. **[blocking] `purge claude memory` removed `projects/<key>/memory/` (bd09c6a, 81b6c2e, c0248b8).**
- claude now takes only a canonical uuid (8-4-4-4-12 hex). Any other id reaches no path at all: purge, `has_artifacts`, residual and in-use all return nothing for it.
- An uppercase or mixed-case uuid is lowercased first, so its in-use check matches the live session's lowercase id (APFS is case-insensitive by default).
- The shared `_plausible_id` is unchanged, so other adapters' ids (`ses_...`) still work.
2. **[P1] a linked parent let rmtree delete outside the owned roots (6e0361c, dd292c1).**
- Each candidate keeps the root it was found under (config, projects or tmp). It is removed only when every component between that root and the leaf is a real directory and the parent resolves inside the resolved root.
- A root may itself be a link (`/tmp -> /private/tmp`). A leaf link is still removed as the link alone.
- A candidate behind a link is not removed; it is reported as residual. A purge whose only leftover lies behind a link now exits 0 and lists it, instead of exiting 5 with nothing.
3. **[P2] `startedAt` NaN made a live holder look stale (f943b0d, 60779ba).**
- `startedAt` must be an int or float (not bool), finite and positive. Anything else, including an integer too large for a float, counts as live.
Non-blocking notes from the comment:
- lstart is compared in UTC: `ps` runs with `TZ=UTC0` and `calendar.timegm` reads it, so the hour a DST change repeats can no longer shift it (af8ed39). No `/proc`.
- The stop exit-4 refusal now names where the holder may be: claude's real `sessions/` dir (following `CLAUDE_CONFIG_DIR`) and `ps -axww | grep <uuid>` for any process naming the uuid. It says "records preserved", and so does the SKILL.md exit-4 row, which is not a code block, so no inventory hash moves (e156179).
- The new tests point every tmp-root candidate, including the fixed `/tmp` fallback, at the test's own dirs, in the test process and in every python a test starts (26fb1c4).
- agy: stop's in-use gate now applies to agy too. Where another process keeps agy's DB or lock open after the CLI exits, a purge that used to run now exits 4. That is the intended fail-closed behaviour; it was not tried with a real agy.
Verification:
- Tests: the new tests run on `26fb1c4` (the test-isolation hook only, before the three fixes) give 29 failed / 71 passed; on `2a8d738`, 100 passed (`test_issue31_purge_residual.py` + `test_a4_adapter_contract.py`).
- Review: the fix loop's third round left one item, the `test_a4` fixture; the user approved that edit (2a8d738). Then a review-only loop (`--base 6f4f40a --all-reviewer`): claude, agy, grok and codex all PASS `2a8d738` in round 1.
- Real-claude trial at `2a8d738`: while live, `in-use` and `purge` both exited 4 (with the new hints) and removed nothing. `stop --purge-conversation --yes` exited 0, purged the jsonl, `file-history/<uuid>`, `session-env/<uuid>` and the tmp scratchpad, printed `residual: none (agent: claude)`, and removed the row. Nothing carrying the uuid was left; the real control `.mam/delegate_job_logs` was unchanged.
- Full suite (Linux) at `2a8d738`: 1958 passed, 20 skipped, 12 failed. The 12 are the known `tests/test_issue10_13_purge.py` failures (#28); their messages match `6f4f40a` apart from a random uuid the test generates. Nothing under `~/.claude` (`projects`, `file-history`, `session-env`, `sessions`, `jobs`) or `/tmp/claude-1000` appeared or disappeared, and the real `.mam/delegate_job_logs` was unchanged.
## Review
- `/multi-agent-mux-loop --plan --all-reviewer --max-loop 3`. The plan (rev 2) took the creator's challenge: the stop process gate and the pid-reuse check.
- Round 1:
- grok: NOT PASS. The doc blocks and the inventory had not been committed.
- codex: NOT PASS.
- P1: `CLAUDE_CONFIG_DIR` was ignored, so a live session under it could be purged.
- P2: a partial failure could hide leftovers.
- Round 2: codex NOT PASS. P2: stop's uuid lookup did not follow `CLAUDE_CONFIG_DIR`.
- Round 3: claude, agy, grok and codex all returned PASS on `6f4f40a`.
- Follow-ups noted by reviewers, not in this PR:
- the #55 test guard still watches `CLAUDE_PROJECT_DIR or <home>/.claude/projects`, not `CLAUDE_CONFIG_DIR`;
- an in-use check for opencode;
- what `codex delete`, `opencode session delete` and `hermes sessions delete` actually remove.
## Verification by the orchestrator, at 6f4f40a (before the Mac fixes)
- **Full suite (Linux):** 1923 passed, 20 skipped, 12 failed. The 12 are the known `tests/test_issue10_13_purge.py` failures (#28). Their messages are identical on `26e2c43` and `6f4f40a`, so this branch neither adds nor changes a failure in that file.
- Run with the agent-pane environment (`CLAUDE_PROJECT_DIR` set), and with `MAM_JOBS_DIR` and `DELEGATE_JOB_LOGS_DIR` unset.
- `~/.claude/projects` and the real `.mam/delegate_job_logs` were the same before and after.
- **Real-claude trial:** a real claude on a throwaway herdr session wrote and edited a file.
- While it ran, `in-use` and `purge` both exited 4 and removed nothing.
- `stop --purge-conversation --yes` then exited 0. It purged the jsonl, `file-history/<uuid>`, `session-env/<uuid>` and the tmp scratchpad, and printed `residual: none (agent: claude)`. The row was gone from the YAML and the DB.
- Afterwards nothing under `~/.claude` or `/tmp/claude-1000` carried the uuid, by name or inside `sessions/*.json`.
- The real control `.mam/delegate_job_logs` was unchanged.
- **Not covered:** Claude Code also makes a scratchpad under a second, unrelated uuid at start and resume (seen in the trial). It has no link to the conversation on disk, so it is left alone (plan §10).
- **Not run here:** macOS `/usr/bin/python3` 3.9.6 and bash 3.2. The changed Python parses with the 3.9 grammar; `bash -n` passes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The claude purge removed only <uuid>.jsonl. It now removes everything the
session alone owns: projects/<key>/<uuid>/, file-history/<uuid>/,
session-env/<uuid>/, jobs/<uuid[:8]>/ when its state.json names the session,
a stale sessions/<pid>.json with that pid's key file (name only, never read)
and <tmp>/claude-<uid>/*/<uuid>/. An id that is empty, all dots or holds
/, .. or * builds no path. has_artifacts finds leftovers without the transcript,
and conversation_in_use also sees a live claude through sessions/<pid>.json
(a `claude -c` names no uuid on its command line); a reused pid is told
apart by ps lstart against the file's startedAt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude had no residual_artifacts, so a purge always printed `residual: none`.
It now reports names holding the uuid under ~/.claude (projects excluded),
in projects two levels deep, in the tmp roots, plus the owned jobs dir and
session files. Shared files (history.jsonl, ~/.claude.json) are not
reported: they always mention every session. scan_names goes to base.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stop called purge_artifacts only. It now prints `WARN residual: <path>` for
each leftover or `residual: none (agent: <agent>)`, the strings orc_onboard
prints. The row is still removed and the exit stays 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
이 PR 이전에는 같은 입력이 존재하지 않는 memory.jsonl 만 겨냥해 무해했습니다. 삭제 단위를 디렉터리로 넓히면서 새로 생긴 회귀이고, 지워지는 것은 복구할 수 없는 사용자 메모리입니다.
도달 경로:
스킬 진입점은 UUID 를 검증한 값만 넘겨서 안전합니다(orc_onboard.sh 정규식, stop_session.sh 의 find_workspace_uuid).
위험한 것은 문서화된 python -m lib_py.agents purge CLI 와 어댑터 API 를 직접 쓰는 경우입니다.
CLI 탐침이 이 호스트에서 rc 4 로 거부된 것은, 다른 프로세스의 argv 에 우연히 "memory" 가 있었기 때문입니다.
수정 방향:
claude 어댑터(_owned_paths·residual_artifacts, 또는 purge/has 입구)에서 정규 UUID([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}, 대소문자 무시)만 받습니다.
다른 어댑터의 식별자(ses_... 등)까지 막지 않도록 공용 _plausible_id 는 그대로 둡니다.
회귀 테스트: bad 목록에 "memory", 공백, 평범한 비-UUID 이름을 추가하고, 공유 memory 파일이 살아 있는지 단언합니다.
2. [P1] 상위 경로의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다 (codex R1)
claude.py 는 tmp 루트의 모든 entry 를 <key> 로 보고 root/<key>/<uuid> 를 후보에 넣습니다. 이때 <key> 가 바깥을 가리키는 링크인지 검사하지 않습니다.
_remove 는 최종 경로 자체가 링크인지만 확인합니다. 그래서 상위 <key> 가 링크이면 <key>/<uuid> 는 일반 디렉터리로 보이고, shutil.rmtree 가 링크 너머의 실제 디렉터리를 지웁니다.
projects·file-history·session-env 경로에도 상위 구성요소의 경계 검증이 없습니다.
재현 (모두 임시 디렉터리):
<ctmp>/claude-<uid>/linked-workspace → <tmp>/outside-owned-roots 를 만들고 outside-owned-roots/<U>/keep.txt 를 둡니다.
purge 하면 결과는 {"victim_exists": false, "link_survives": true, "purged": [".../claude-<uid>/linked-workspace/<U>"]} 입니다.
대조군: 최종 <uuid> 자체가 링크인 경우에는 링크만 지워지고 대상은 남았습니다.
수정 방향:
설정된 루트를 신뢰 경계로 정규화한 뒤, 그 아래에서 만난 심볼릭 링크 디렉터리는 따라가지 않습니다(lstat/no-follow).
realpath 가 경계 밖인 후보는 지우지 말고 residual 로 보고합니다.
회귀 테스트: 경로 끝 링크 대조군과, 상위 링크를 통한 탈출 경우를 둘 다 넣습니다.
3. [P2] startedAt 이 NaN 이면 살아 있는 프로세스가 죽은 것으로 판정된다 (codex R3)
float(data["startedAt"]) 는 "NaN" 과 JSON NaN 을 예외 없이 변환합니다.
그러면 <= 0 과 lstart <= started + 2 가 모두 거짓이 되어, 살아 있는 pid 에 대해 False(free)를 돌려줍니다. 그 세션 파일은 소유 경로로 선택되고, stop 의 in-use 게이트도 통과합니다.
재현 (/bin/sleep 60 + 임시 sessions/<pid>.json 의 "startedAt": "NaN"): python -m lib_py.agents in-use claude <U> <ws> 가 rc 0 과 {"in_use": false} 를 돌려줍니다. 이때 holder 는 살아 있습니다.
수정 방향:
변환 후 math.isfinite 와 유효 범위를 확인하고, 아니면 live(판정 불가)로 처리합니다. bool 등 시각이 아닌 타입도 배제합니다.
테스트: NaN·무한대·결손·파싱 불가 값을 정상 live/dead/reused pid 경우와 함께 검사합니다.
비차단 메모
서머타임(DST):time.mktime(time.strptime(...)) 는 서머타임이 끝나는 겹치는 1시간에 lstart 를 1시간 늦게 해석할 수 있습니다(fail-open). 실제 세션 파일의 procStart(UTC) 를 쓰거나 UTC 로 비교하는 것을 고려해 볼 만합니다.
exit 4 안내 문구: stop 의 exit 4 메시지가 ~/.claude/sessions/ 만 가리킵니다. 그런데 CLAUDE_CONFIG_DIR 를 쓰면 위치가 다르고, argv 에 uuid 를 담은 다른 프로세스(에디터·grep 등)도 거부 사유가 됩니다. ps -axww | grep <uuid> 안내를 함께 두면 좋겠습니다.
SKILL.md exit 4 행: "(nothing was changed)" 라고 적혀 있지만, 프로세스 게이트는 herdr kill 이후에 돌아서 세션은 이미 내려가 있을 수 있습니다. "records preserved" 가 더 정확합니다.
agy 에도 in-use 게이트 적용: stop 이 이제 모든 에이전트에 이 게이트를 적용합니다. CLI 종료 뒤에도 다른 프로세스가 DB/락을 열어 두는 agy 구성에서는, 전에 purge 되던 것이 exit 4 로 바뀝니다. 의도된 fail-closed 지만 실제 agy 로는 확인하지 않았습니다.
테스트 격리: 테스트가 하드코딩된 /tmp/claude-<uid> 후보를 격리하지 못해, 실제 경로를 나열합니다. 무작위 uuid 라 실제로 지워지는 것은 없었지만(스위트 전후 비교로 확인), _tmp_roots 를 테스트에서 패치할 수 있게 하면 더 안전합니다.
수정 커밋을 fix/issue-31 에 push 해 주시면, 같은 절차로 다시 검증하겠습니다: 4인 리뷰 → Mac 전체 스위트 → 릴리스 커밋(v4.9.5, eager.CORE_MODULES 에 lib_py.delegation.logroot 추가 포함) → 병합.
## 병합 전 검증 결과 (macOS · Python 3.9.6 · bash 3.2): 수정 필요 3건
head `6f4f40a` 기준으로 검증했습니다.
**전체 스위트 (이 Mac): 1899 passed, 56 skipped, 0 failed**
- 마지막 #55 가드 오류 1건은 다른 워크스페이스에서 돌던 agy 의 대화 WAL/SHM 을 잡은 알려진 오탐입니다.
- 실제 홈 전후 비교:
- `~/.claude` 의 `projects`·`file-history`·`session-env`·`sessions`·`jobs` 와 `/private/tmp/claude-<uid>` 에서 사라진 항목이 없습니다.
- 새로 생긴 항목은 스위트 도중 다른 워크스페이스에서 시작된 claude 세션의 정상 파일뿐입니다.
- 실제 herdr pane 목록은 실행 전후가 같습니다.
**4인 리뷰: agy PASS, grok PASS, claude NOT PASS, codex NOT PASS**
- 아래 3건은 스위트가 다루지 않는 경우입니다. 리뷰어들이 임시 홈과 합성 sentinel 로 재현했고, 실제 홈·tmp·에이전트에는 실행하지 않았습니다.
- 모두 삭제 범위의 안전성 문제라 병합 전에 수정이 필요합니다.
**이 Mac 에서 맞게 동작한 것**
- BSD `ps -o lstart=` 파싱(LC_ALL=C 출력 `Fri Oct 16 08:25:14 2026`)과 pid 재사용 판정
- `/tmp → /private/tmp`, `TMPDIR=/var/folders/.../T` 레이아웃에서의 스크래치패드 탐색
- 실제 `~/.claude` 구조(`sessions/<pid>.json`+`.key`, `jobs/<8hex>/state.json`, `file-history`, `session-env`, `projects/<key>/<uuid>/`)가 어댑터의 가정과 일치
- 다른 세션·공유 파일 보존, 경로 끝(leaf) 심볼릭 링크 보호, stop exit 4 계약, Python 3.9·bash 3.2, 문서와 `skill_code_blocks.json` 정합성
### 1. [차단] UUID 가 아닌 id `memory` 로 purge 하면 자동 메모리 디렉터리가 통째로 지워진다 (claude F1, codex R2)
- `~/.claude/projects/<key>/` 에는 세션 파일·디렉터리와 같은 층에 Claude Code 자동 메모리 디렉터리 `memory/` 가 있습니다. 세션 id 와 같은 층에 있는 유일한 비-UUID 이름입니다.
- `base.py` `_plausible_id` 는 빈 문자열·점·`/`·`..`·`*` 만 거부합니다.
- 그런데 `claude.py` `_owned_paths` 는 이 검사만 거치고 `projects/<key>/<id>` 디렉터리를 소유 경로로 넣습니다. `has_artifacts` 와 purge 가 같은 목록을 씁니다.
- 재현 (임시 HOME):
- `has_artifacts("memory") → True`
- `purge_artifacts("memory") → [.../projects/<key>/memory, .../file-history/memory]`
- `memory/MEMORY.md` 가 사라집니다.
- 이 PR 이전에는 같은 입력이 존재하지 않는 `memory.jsonl` 만 겨냥해 무해했습니다. 삭제 단위를 디렉터리로 넓히면서 새로 생긴 회귀이고, 지워지는 것은 복구할 수 없는 사용자 메모리입니다.
- 도달 경로:
- 스킬 진입점은 UUID 를 검증한 값만 넘겨서 안전합니다(`orc_onboard.sh` 정규식, `stop_session.sh` 의 `find_workspace_uuid`).
- 위험한 것은 문서화된 `python -m lib_py.agents purge` CLI 와 어댑터 API 를 직접 쓰는 경우입니다.
- CLI 탐침이 이 호스트에서 rc 4 로 거부된 것은, 다른 프로세스의 argv 에 우연히 "memory" 가 있었기 때문입니다.
- 수정 방향:
- claude 어댑터(`_owned_paths`·`residual_artifacts`, 또는 purge/has 입구)에서 정규 UUID(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`, 대소문자 무시)만 받습니다.
- 다른 어댑터의 식별자(`ses_...` 등)까지 막지 않도록 공용 `_plausible_id` 는 그대로 둡니다.
- 회귀 테스트: `bad` 목록에 `"memory"`, 공백, 평범한 비-UUID 이름을 추가하고, 공유 memory 파일이 살아 있는지 단언합니다.
### 2. [P1] 상위 경로의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다 (codex R1)
- `claude.py` 는 tmp 루트의 모든 entry 를 `<key>` 로 보고 `root/<key>/<uuid>` 를 후보에 넣습니다. 이때 `<key>` 가 바깥을 가리키는 링크인지 검사하지 않습니다.
- `_remove` 는 최종 경로 자체가 링크인지만 확인합니다. 그래서 상위 `<key>` 가 링크이면 `<key>/<uuid>` 는 일반 디렉터리로 보이고, `shutil.rmtree` 가 링크 너머의 실제 디렉터리를 지웁니다.
- projects·file-history·session-env 경로에도 상위 구성요소의 경계 검증이 없습니다.
- 재현 (모두 임시 디렉터리):
- `<ctmp>/claude-<uid>/linked-workspace → <tmp>/outside-owned-roots` 를 만들고 `outside-owned-roots/<U>/keep.txt` 를 둡니다.
- purge 하면 결과는 `{"victim_exists": false, "link_survives": true, "purged": [".../claude-<uid>/linked-workspace/<U>"]}` 입니다.
- 대조군: 최종 `<uuid>` 자체가 링크인 경우에는 링크만 지워지고 대상은 남았습니다.
- 수정 방향:
- 설정된 루트를 신뢰 경계로 정규화한 뒤, 그 아래에서 만난 심볼릭 링크 디렉터리는 따라가지 않습니다(`lstat`/no-follow).
- realpath 가 경계 밖인 후보는 지우지 말고 residual 로 보고합니다.
- 회귀 테스트: 경로 끝 링크 대조군과, 상위 링크를 통한 탈출 경우를 둘 다 넣습니다.
### 3. [P2] `startedAt` 이 NaN 이면 살아 있는 프로세스가 죽은 것으로 판정된다 (codex R3)
- `float(data["startedAt"])` 는 `"NaN"` 과 JSON NaN 을 예외 없이 변환합니다.
- 그러면 `<= 0` 과 `lstart <= started + 2` 가 모두 거짓이 되어, 살아 있는 pid 에 대해 `False`(free)를 돌려줍니다. 그 세션 파일은 소유 경로로 선택되고, stop 의 in-use 게이트도 통과합니다.
- 재현 (`/bin/sleep 60` + 임시 `sessions/<pid>.json` 의 `"startedAt": "NaN"`): `python -m lib_py.agents in-use claude <U> <ws>` 가 rc 0 과 `{"in_use": false}` 를 돌려줍니다. 이때 holder 는 살아 있습니다.
- 수정 방향:
- 변환 후 `math.isfinite` 와 유효 범위를 확인하고, 아니면 live(판정 불가)로 처리합니다. bool 등 시각이 아닌 타입도 배제합니다.
- 테스트: NaN·무한대·결손·파싱 불가 값을 정상 live/dead/reused pid 경우와 함께 검사합니다.
### 비차단 메모
- **서머타임(DST):** `time.mktime(time.strptime(...))` 는 서머타임이 끝나는 겹치는 1시간에 lstart 를 1시간 늦게 해석할 수 있습니다(fail-open). 실제 세션 파일의 `procStart`(UTC) 를 쓰거나 UTC 로 비교하는 것을 고려해 볼 만합니다.
- **exit 4 안내 문구:** stop 의 exit 4 메시지가 `~/.claude/sessions/` 만 가리킵니다. 그런데 `CLAUDE_CONFIG_DIR` 를 쓰면 위치가 다르고, argv 에 uuid 를 담은 다른 프로세스(에디터·grep 등)도 거부 사유가 됩니다. `ps -axww | grep <uuid>` 안내를 함께 두면 좋겠습니다.
- **SKILL.md exit 4 행:** "(nothing was changed)" 라고 적혀 있지만, 프로세스 게이트는 herdr kill 이후에 돌아서 세션은 이미 내려가 있을 수 있습니다. "records preserved" 가 더 정확합니다.
- **agy 에도 in-use 게이트 적용:** stop 이 이제 모든 에이전트에 이 게이트를 적용합니다. CLI 종료 뒤에도 다른 프로세스가 DB/락을 열어 두는 agy 구성에서는, 전에 purge 되던 것이 exit 4 로 바뀝니다. 의도된 fail-closed 지만 실제 agy 로는 확인하지 않았습니다.
- **테스트 격리:** 테스트가 하드코딩된 `/tmp/claude-<uid>` 후보를 격리하지 못해, 실제 경로를 나열합니다. 무작위 uuid 라 실제로 지워지는 것은 없었지만(스위트 전후 비교로 확인), `_tmp_roots` 를 테스트에서 패치할 수 있게 하면 더 안전합니다.
수정 커밋을 `fix/issue-31` 에 push 해 주시면, 같은 절차로 다시 검증하겠습니다: 4인 리뷰 → Mac 전체 스위트 → 릴리스 커밋(v4.9.5, `eager.CORE_MODULES` 에 `lib_py.delegation.logroot` 추가 포함) → 병합.
The adapter's fixed /tmp fallback is now a module constant. The new tests
point it, TMPDIR, TMP and TEMP at the test's own dirs, in the test process
and (through a sitecustomize on PYTHONPATH) in every python a test starts,
so no test lists the real /tmp/claude-<uid> any more (Mac review, PR #59).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
projects/<key>/memory/ (Claude Code's auto memory) sits beside the session
dirs, and `purge claude memory` removed it whole. Only an 8-4-4-4-12 hex id
now reaches the session's directories, session files, jobs and tmp dirs. Any
other id keeps the pre-#31 reach, the `<id>.jsonl` file alone (test_a4 still
purges "uuid-c"); its residual is that file and nothing holds it in use.
The shared _plausible_id is unchanged for the other adapters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tmp-root entry <key> linking outside made `<key>/<uuid>` look like a plain
directory, and rmtree removed what lay behind the link; projects/<key>,
file-history/ and the other config dirs had no such check either. Each
candidate now keeps the root it was found under (config, projects or tmp),
and is purged only when every component between that root and the leaf is a
real directory and its parent resolves inside the resolved root. A root may
itself be a link (/tmp -> /private/tmp), a leaf link is still removed as the
link alone, and a candidate behind a link is reported as residual instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
float("NaN") and a JSON NaN passed the old check, and every comparison with
NaN is false, so a live holder looked like a reused pid: the in-use gate let
the purge through and the session file was taken as stale. startedAt must
now be an int or float (not bool), finite and positive; anything else cannot
be told and counts as live. A dead pid is still stale whatever it says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
time.mktime read ps's local lstart, which is ambiguous in the hour a DST
change repeats: the start could come out an hour off and a live holder be
taken for a reused pid (or the reverse). ps now runs with TZ=UTC0, which BSD
ps and procps both honour, and calendar.timegm reads it. No /proc.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal pointed at ~/.claude/sessions/ for every agent, whatever
CLAUDE_CONFIG_DIR said, and never mentioned a non-agent process naming the
uuid on its command line. The in-use CLI now prints, on stderr while the
conversation is in use, the adapter's in_use_hints: for every adapter
`ps -axww | grep <uuid>`, and for claude its real sessions/ dir first. stop
prints them under its refusal, and says "records preserved" instead of
"no state was modified": the gate runs after the kill chain, so the session
itself may already be down. SKILL.md's exit 4 row says the same; it is not
a code block, so no inventory hash moves.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
json.load reads a huge integer as an int, and math.isfinite raised
OverflowError on it outside any try: conversation_in_use and purge_artifacts
(which must never raise) threw, where 6f4f40a had taken it as live. The
conversion to float now comes first, and an overflow means live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_is_uuid took an uppercase or mixed-case uuid, and the paths built from it
are the live lowercase session's own files on macOS's default
case-insensitive APFS. The in-use checks compare exactly, though: session
files and command lines carry the lowercase id, so `purge claude <UPPER>`
found no holder and could remove a live session's files. _canonical now
lowercases a canonical uuid, and the paths, the session-file and job
matches, the in-use check and the residual all use it; a command line is
searched for both spellings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has_artifacts counted only the paths a purge may remove. When all that was
left lay behind a linked dir, `purge claude <uuid>` (or auto) found no
adapter and exited 5 with an empty residual, hiding the path the purge
had refused to follow. has_artifacts now counts every candidate; _inside
still decides what is removed, so the CLI exits 0, removes nothing and
lists the linked path as residual.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user asked for no path at all from an id that is not a uuid. bd09c6a
still took `<id>.jsonl` for one (the pre-#31 reach), which has_artifacts and
the residual counted while in-use always said free: `purge claude notes`
deleted notes.jsonl. Now purge, has_artifacts and residual return nothing
for such an id, as in-use already did.
This makes test_a4_adapter_contract.py::test_purge_artifacts_composite fail:
it purges the claude id 'uuid-c' and expects uuid-c.jsonl gone. Editing an
existing test needs the user's approval; the one-line data change
(a canonical uuid in place of 'uuid-c') is kept outside the branch, in job
90efa4ce's report dir. Reverting this commit restores the jsonl-only reach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude now takes only a canonical uuid (81b6c2e), so the fixture id
'uuid-c' purged nothing. The three claude spellings in
test_purge_artifacts_composite become one uuid literal; the assertions
and the other adapters' parts are unchanged. Approved by the user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS /bin/sh (bash 3.2) execs the lone `sleep 120` of `sh -c "sleep 120" <uuid>`,
so the uuid left the process list and both [upper] and [mixed] failed on the Mac.
A Python holder keeps the uuid in argv, like _holder() in test_issue10_13_purge.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
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
Godopu
merged commit 74333f87d0 into main2026-10-17 16:25:21 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #31
A claude
--purge-conversationnow removes every path the session owns, reports whatever still carries the uuid, and refuses while a claude process still holds the conversation. The stop docs now say what the code does: a purge removes the registry row. No version bump; the version is set at merge.What a claude purge removes (
9ccd3c1,f542596)_owned_paths, lists the session's paths. Purge,has_artifactsand the residual scan all use it.projects/<key>/<uuid>.jsonlandprojects/<key>/<uuid>/(tool results);file-history/<uuid>/andsession-env/<uuid>/;jobs/<uuid[:8]>/, only when itsstate.jsonnames this session;sessions/<pid>.jsonand that pid's<pid>.<hex>.key. The key is matched by name and never read, and the output masks its hex;<tmp>/claude-<uid>/<key>/<uuid>/(the scratchpad). The tmp roots followCLAUDE_CODE_TMPDIR,TMPDIR/TMP/TEMPand/tmp, as Claude Code does.history.jsonl,~/.claude.json, backups, paste-cache) are not rewritten..,..,*and anything with/were refused).state.jsonand the session file after its key. If a removal fails partway, the leftovers are still reported (review P2,f542596).What it reports (
366da3b,48cda51,b346137)residual_artifacts, under the same contract as agy's.projects, and looks two levels deep inprojectsand in the tmp roots.[], so a purge printedresidual: nonewhatever was left. That was the issue's symptom.stop_session.shnow asks for the residual after the purge and prints it asorc_onboarddoes:WARN residual: <path>for each one, orresidual: none (agent: claude). Before this, stop never asked.eventrows, grok memory observations, codex thread rows) is now printed instead of hidden.A purge waits for the claude process (
ae98cf1)claude -c). Before this, a purge from another shell was not refused. Claude's in-use check now also readssessions/<pid>.json.startedAt+ 2 s (ps -o lstart=underLC_ALL=C). So a reused pid is not mistaken for the session.python -m lib_py.agents in-use <agent> <uuid> [workspace] [--wait <sec>]. It uses the sameconversation_in_useas the purge CLI, so the two cannot disagree.stop_session.sh --purge-conversationnow calls it with--wait 5. It does so even when the herdr session is already gone, since the session ending and the process ending are not the same event.CLAUDE_CONFIG_DIR(8a4f651,f0af989)CLAUDE_CONFIG_DIR, else<home>/.claude. The purge, liveness and residual paths all use it.lib.shand inDiscoveryContext, so stop finds the transcript before purging. An explicitCLAUDE_PROJECT_DIRstill wins.tests/_agent_home_guard.pydropsCLAUDE_CONFIG_DIRfor every test, like the other agent-home variables.test_issue55requires every variable the adapters read to be listed there.Docs: a purge removes the row (
6328e67,57830cf)terminatedrow, butstop_session.shremoves the row from the YAML and the DB. The docs now say the row is removed.stoppedrowterminated.tests/data/skill_code_blocks.jsonhold. The inventory changes are the two edited blocks'shavalues and one passthrough line reference (stop_session.sh:310→:327).Tests
tests/test_issue31_purge_residual.py(32 tests).26e2c43): 24 fail and 8 pass. The 8 that pass check what must survive a purge: other sessions and shared files, a job dir of another session with the same prefix, and malformed uuids (6 cases).tests/_agent_home_guard.py(one variable added);tests/data/skill_code_blocks.json(above);tests/test_a4_adapter_contract.py: the composite purge names its claude session by a real uuid instead of'uuid-c'(2a8d738, approved after the Mac review).Fixes from the Mac verification (6f4f40a..2a8d738)
The Mac review (PR comment) found three purge-safety bugs. All three were reproduced on Linux in a temp home before the fix and are blocked at
2a8d738.purge claude memoryremovedprojects/<key>/memory/(bd09c6a,81b6c2e,c0248b8).has_artifacts, residual and in-use all return nothing for it._plausible_idis unchanged, so other adapters' ids (ses_...) still work.6e0361c,dd292c1)./tmp -> /private/tmp). A leaf link is still removed as the link alone.startedAtNaN made a live holder look stale (f943b0d,60779ba).startedAtmust be an int or float (not bool), finite and positive. Anything else, including an integer too large for a float, counts as live.Non-blocking notes from the comment:
psruns withTZ=UTC0andcalendar.timegmreads it, so the hour a DST change repeats can no longer shift it (af8ed39). No/proc.sessions/dir (followingCLAUDE_CONFIG_DIR) andps -axww | grep <uuid>for any process naming the uuid. It says "records preserved", and so does the SKILL.md exit-4 row, which is not a code block, so no inventory hash moves (e156179)./tmpfallback, at the test's own dirs, in the test process and in every python a test starts (26fb1c4).Verification:
26fb1c4(the test-isolation hook only, before the three fixes) give 29 failed / 71 passed; on2a8d738, 100 passed (test_issue31_purge_residual.py+test_a4_adapter_contract.py).test_a4fixture; the user approved that edit (2a8d738). Then a review-only loop (--base 6f4f40a --all-reviewer): claude, agy, grok and codex all PASS2a8d738in round 1.2a8d738: while live,in-useandpurgeboth exited 4 (with the new hints) and removed nothing.stop --purge-conversation --yesexited 0, purged the jsonl,file-history/<uuid>,session-env/<uuid>and the tmp scratchpad, printedresidual: none (agent: claude), and removed the row. Nothing carrying the uuid was left; the real control.mam/delegate_job_logswas unchanged.2a8d738: 1958 passed, 20 skipped, 12 failed. The 12 are the knowntests/test_issue10_13_purge.pyfailures (#28); their messages match6f4f40aapart from a random uuid the test generates. Nothing under~/.claude(projects,file-history,session-env,sessions,jobs) or/tmp/claude-1000appeared or disappeared, and the real.mam/delegate_job_logswas unchanged.Review
/multi-agent-mux-loop --plan --all-reviewer --max-loop 3. The plan (rev 2) took the creator's challenge: the stop process gate and the pid-reuse check.CLAUDE_CONFIG_DIRwas ignored, so a live session under it could be purged.CLAUDE_CONFIG_DIR.6f4f40a.CLAUDE_PROJECT_DIR or <home>/.claude/projects, notCLAUDE_CONFIG_DIR;codex delete,opencode session deleteandhermes sessions deleteactually remove.Verification by the orchestrator, at
6f4f40a(before the Mac fixes)tests/test_issue10_13_purge.pyfailures (#28). Their messages are identical on26e2c43and6f4f40a, so this branch neither adds nor changes a failure in that file.CLAUDE_PROJECT_DIRset), and withMAM_JOBS_DIRandDELEGATE_JOB_LOGS_DIRunset.~/.claude/projectsand the real.mam/delegate_job_logswere the same before and after.in-useandpurgeboth exited 4 and removed nothing.stop --purge-conversation --yesthen exited 0. It purged the jsonl,file-history/<uuid>,session-env/<uuid>and the tmp scratchpad, and printedresidual: none (agent: claude). The row was gone from the YAML and the DB.~/.claudeor/tmp/claude-1000carried the uuid, by name or insidesessions/*.json..mam/delegate_job_logswas unchanged./usr/bin/python33.9.6 and bash 3.2. The changed Python parses with the 3.9 grammar;bash -npasses.🤖 Generated with Claude Code
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>병합 전 검증 결과 (macOS · Python 3.9.6 · bash 3.2): 수정 필요 3건
head
6f4f40a기준으로 검증했습니다.전체 스위트 (이 Mac): 1899 passed, 56 skipped, 0 failed
~/.claude의projects·file-history·session-env·sessions·jobs와/private/tmp/claude-<uid>에서 사라진 항목이 없습니다.4인 리뷰: agy PASS, grok PASS, claude NOT PASS, codex NOT PASS
이 Mac 에서 맞게 동작한 것
ps -o lstart=파싱(LC_ALL=C 출력Fri Oct 16 08:25:14 2026)과 pid 재사용 판정/tmp → /private/tmp,TMPDIR=/var/folders/.../T레이아웃에서의 스크래치패드 탐색~/.claude구조(sessions/<pid>.json+.key,jobs/<8hex>/state.json,file-history,session-env,projects/<key>/<uuid>/)가 어댑터의 가정과 일치skill_code_blocks.json정합성1. [차단] UUID 가 아닌 id
memory로 purge 하면 자동 메모리 디렉터리가 통째로 지워진다 (claude F1, codex R2)~/.claude/projects/<key>/에는 세션 파일·디렉터리와 같은 층에 Claude Code 자동 메모리 디렉터리memory/가 있습니다. 세션 id 와 같은 층에 있는 유일한 비-UUID 이름입니다.base.py_plausible_id는 빈 문자열·점·/·..·*만 거부합니다.claude.py_owned_paths는 이 검사만 거치고projects/<key>/<id>디렉터리를 소유 경로로 넣습니다.has_artifacts와 purge 가 같은 목록을 씁니다.has_artifacts("memory") → Truepurge_artifacts("memory") → [.../projects/<key>/memory, .../file-history/memory]memory/MEMORY.md가 사라집니다.memory.jsonl만 겨냥해 무해했습니다. 삭제 단위를 디렉터리로 넓히면서 새로 생긴 회귀이고, 지워지는 것은 복구할 수 없는 사용자 메모리입니다.orc_onboard.sh정규식,stop_session.sh의find_workspace_uuid).python -m lib_py.agents purgeCLI 와 어댑터 API 를 직접 쓰는 경우입니다._owned_paths·residual_artifacts, 또는 purge/has 입구)에서 정규 UUID([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}, 대소문자 무시)만 받습니다.ses_...등)까지 막지 않도록 공용_plausible_id는 그대로 둡니다.bad목록에"memory", 공백, 평범한 비-UUID 이름을 추가하고, 공유 memory 파일이 살아 있는지 단언합니다.2. [P1] 상위 경로의 심볼릭 링크를 따라 소유 루트 밖을 삭제한다 (codex R1)
claude.py는 tmp 루트의 모든 entry 를<key>로 보고root/<key>/<uuid>를 후보에 넣습니다. 이때<key>가 바깥을 가리키는 링크인지 검사하지 않습니다._remove는 최종 경로 자체가 링크인지만 확인합니다. 그래서 상위<key>가 링크이면<key>/<uuid>는 일반 디렉터리로 보이고,shutil.rmtree가 링크 너머의 실제 디렉터리를 지웁니다.<ctmp>/claude-<uid>/linked-workspace → <tmp>/outside-owned-roots를 만들고outside-owned-roots/<U>/keep.txt를 둡니다.{"victim_exists": false, "link_survives": true, "purged": [".../claude-<uid>/linked-workspace/<U>"]}입니다.<uuid>자체가 링크인 경우에는 링크만 지워지고 대상은 남았습니다.lstat/no-follow).3. [P2]
startedAt이 NaN 이면 살아 있는 프로세스가 죽은 것으로 판정된다 (codex R3)float(data["startedAt"])는"NaN"과 JSON NaN 을 예외 없이 변환합니다.<= 0과lstart <= started + 2가 모두 거짓이 되어, 살아 있는 pid 에 대해False(free)를 돌려줍니다. 그 세션 파일은 소유 경로로 선택되고, stop 의 in-use 게이트도 통과합니다./bin/sleep 60+ 임시sessions/<pid>.json의"startedAt": "NaN"):python -m lib_py.agents in-use claude <U> <ws>가 rc 0 과{"in_use": false}를 돌려줍니다. 이때 holder 는 살아 있습니다.math.isfinite와 유효 범위를 확인하고, 아니면 live(판정 불가)로 처리합니다. bool 등 시각이 아닌 타입도 배제합니다.비차단 메모
time.mktime(time.strptime(...))는 서머타임이 끝나는 겹치는 1시간에 lstart 를 1시간 늦게 해석할 수 있습니다(fail-open). 실제 세션 파일의procStart(UTC) 를 쓰거나 UTC 로 비교하는 것을 고려해 볼 만합니다.~/.claude/sessions/만 가리킵니다. 그런데CLAUDE_CONFIG_DIR를 쓰면 위치가 다르고, argv 에 uuid 를 담은 다른 프로세스(에디터·grep 등)도 거부 사유가 됩니다.ps -axww | grep <uuid>안내를 함께 두면 좋겠습니다./tmp/claude-<uid>후보를 격리하지 못해, 실제 경로를 나열합니다. 무작위 uuid 라 실제로 지워지는 것은 없었지만(스위트 전후 비교로 확인),_tmp_roots를 테스트에서 패치할 수 있게 하면 더 안전합니다.수정 커밋을
fix/issue-31에 push 해 주시면, 같은 절차로 다시 검증하겠습니다: 4인 리뷰 → Mac 전체 스위트 → 릴리스 커밋(v4.9.5,eager.CORE_MODULES에lib_py.delegation.logroot추가 포함) → 병합.float("NaN") and a JSON NaN passed the old check, and every comparison with NaN is false, so a live holder looked like a reused pid: the in-use gate let the purge through and the session file was taken as stale. startedAt must now be an int or float (not bool), finite and positive; anything else cannot be told and counts as live. A dead pid is still stale whatever it says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>