feat: add post-migration cleanup for OpenClaw directories (#4100)

After migrating from OpenClaw, leftover workspace directories contain
state files (todo.json, sessions, logs) that confuse the agent — it
discovers them and reads/writes to stale locations instead of the
Hermes state directory, causing issues like cron jobs reading a
different todo list than interactive sessions.

Changes:
- hermes claw migrate now offers to archive the source directory after
  successful migration (rename to .pre-migration, not delete)
- New `hermes claw cleanup` subcommand for users who already migrated
  and need to archive leftover OpenClaw directories
- Migration notes updated with explicit cleanup guidance
- 42 tests covering all new functionality

Reported by SteveSkedasticity — multiple todo.json files across
~/.hermes/, ~/.openclaw/workspace/, and ~/.openclaw/workspace-assistant/
caused cron jobs to read from wrong locations.
This commit is contained in:
Teknium
2026-03-30 17:39:08 -07:00
committed by GitHub
parent 8a794d029d
commit 720507efac
4 changed files with 649 additions and 2 deletions

View File

@@ -40,6 +40,119 @@ class TestFindMigrationScript:
assert claw_mod._find_migration_script() is None
# ---------------------------------------------------------------------------
# _find_openclaw_dirs
# ---------------------------------------------------------------------------
class TestFindOpenclawDirs:
"""Test discovery of OpenClaw directories."""
def test_finds_openclaw_dir(self, tmp_path):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert openclaw in found
def test_finds_legacy_dirs(self, tmp_path):
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
moldbot = tmp_path / ".moldbot"
moldbot.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert len(found) == 2
assert clawdbot in found
assert moldbot in found
def test_returns_empty_when_none_exist(self, tmp_path):
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert found == []
# ---------------------------------------------------------------------------
# _scan_workspace_state
# ---------------------------------------------------------------------------
class TestScanWorkspaceState:
"""Test scanning for workspace state files."""
def test_finds_root_state_files(self, tmp_path):
(tmp_path / "todo.json").write_text("{}")
(tmp_path / "sessions").mkdir()
findings = claw_mod._scan_workspace_state(tmp_path)
descs = [desc for _, desc in findings]
assert any("todo.json" in d for d in descs)
assert any("sessions" in d for d in descs)
def test_finds_workspace_state_files(self, tmp_path):
ws = tmp_path / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
(ws / "sessions").mkdir()
findings = claw_mod._scan_workspace_state(tmp_path)
descs = [desc for _, desc in findings]
assert any("workspace/todo.json" in d for d in descs)
assert any("workspace/sessions" in d for d in descs)
def test_ignores_hidden_dirs(self, tmp_path):
scan_dir = tmp_path / "scan_target"
scan_dir.mkdir()
hidden = scan_dir / ".git"
hidden.mkdir()
(hidden / "todo.json").write_text("{}")
findings = claw_mod._scan_workspace_state(scan_dir)
assert len(findings) == 0
def test_empty_dir_returns_empty(self, tmp_path):
scan_dir = tmp_path / "scan_target"
scan_dir.mkdir()
findings = claw_mod._scan_workspace_state(scan_dir)
assert findings == []
# ---------------------------------------------------------------------------
# _archive_directory
# ---------------------------------------------------------------------------
class TestArchiveDirectory:
"""Test directory archival (rename)."""
def test_renames_to_pre_migration(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
(source / "test.txt").write_text("data")
archive_path = claw_mod._archive_directory(source)
assert archive_path == tmp_path / ".openclaw.pre-migration"
assert archive_path.is_dir()
assert not source.exists()
assert (archive_path / "test.txt").read_text() == "data"
def test_adds_timestamp_when_archive_exists(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
# Pre-existing archive
(tmp_path / ".openclaw.pre-migration").mkdir()
archive_path = claw_mod._archive_directory(source)
assert ".pre-migration-" in archive_path.name
assert archive_path.is_dir()
assert not source.exists()
def test_dry_run_does_not_rename(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
archive_path = claw_mod._archive_directory(source, dry_run=True)
assert archive_path == tmp_path / ".openclaw.pre-migration"
assert source.is_dir() # Still exists
# ---------------------------------------------------------------------------
# claw_command routing
# ---------------------------------------------------------------------------
@@ -56,11 +169,24 @@ class TestClawCommand:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_routes_to_cleanup(self):
args = Namespace(claw_action="cleanup", source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_cmd_cleanup") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_routes_clean_alias(self):
args = Namespace(claw_action="clean", source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_cmd_cleanup") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_shows_help_for_no_action(self, capsys):
args = Namespace(claw_action=None)
claw_mod.claw_command(args)
captured = capsys.readouterr()
assert "migrate" in captured.out
assert "cleanup" in captured.out
# ---------------------------------------------------------------------------
@@ -168,6 +294,7 @@ class TestCmdMigrate:
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=True),
patch.object(claw_mod, "_offer_source_archival"),
):
claw_mod._cmd_migrate(args)
@@ -175,6 +302,75 @@ class TestCmdMigrate:
assert "Migration Results" in captured.out
assert "Migration complete!" in captured.out
def test_execute_offers_archival_on_success(self, tmp_path, capsys):
"""After successful migration, _offer_source_archival should be called."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=True,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
):
claw_mod._cmd_migrate(args)
mock_archival.assert_called_once_with(openclaw_dir, True)
def test_dry_run_skips_archival(self, tmp_path, capsys):
"""Dry run should not offer archival."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 2, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
"preset": "full",
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
):
claw_mod._cmd_migrate(args)
mock_archival.assert_not_called()
def test_execute_cancelled_by_user(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
@@ -290,6 +486,172 @@ class TestCmdMigrate:
assert call_kwargs["migrate_secrets"] is True
# ---------------------------------------------------------------------------
# _offer_source_archival
# ---------------------------------------------------------------------------
class TestOfferSourceArchival:
"""Test the post-migration archival offer."""
def test_archives_with_auto_yes(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
(source / "workspace").mkdir()
(source / "workspace" / "todo.json").write_text("{}")
claw_mod._offer_source_archival(source, auto_yes=True)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert not source.exists()
assert (tmp_path / ".openclaw.pre-migration").is_dir()
def test_skips_when_user_declines(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
claw_mod._offer_source_archival(source, auto_yes=False)
captured = capsys.readouterr()
assert "Skipped" in captured.out
assert source.is_dir() # Still exists
def test_noop_when_source_missing(self, tmp_path, capsys):
claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True)
captured = capsys.readouterr()
assert captured.out == "" # No output
def test_shows_state_files(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
ws = source / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
claw_mod._offer_source_archival(source, auto_yes=False)
captured = capsys.readouterr()
assert "todo.json" in captured.out
def test_handles_archive_error(self, tmp_path, capsys):
source = tmp_path / ".openclaw"
source.mkdir()
with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")):
claw_mod._offer_source_archival(source, auto_yes=True)
captured = capsys.readouterr()
assert "Could not archive" in captured.out
# ---------------------------------------------------------------------------
# _cmd_cleanup
# ---------------------------------------------------------------------------
class TestCmdCleanup:
"""Test the cleanup command handler."""
def test_no_dirs_found(self, tmp_path, capsys):
args = Namespace(source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "No OpenClaw directories found" in captured.out
def test_dry_run_lists_dirs(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
ws = openclaw / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
args = Namespace(source=None, dry_run=True, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Would archive" in captured.out
assert openclaw.is_dir() # Not actually archived
def test_archives_with_yes(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
(openclaw / "workspace").mkdir()
(openclaw / "workspace" / "todo.json").write_text("{}")
args = Namespace(source=None, dry_run=False, yes=True)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert "Cleaned up 1" in captured.out
assert not openclaw.exists()
assert (tmp_path / ".openclaw.pre-migration").is_dir()
def test_skips_when_user_declines(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
args = Namespace(source=None, dry_run=False, yes=False)
with (
patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]),
patch.object(claw_mod, "prompt_yes_no", return_value=False),
):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Skipped" in captured.out
assert openclaw.is_dir()
def test_explicit_source(self, tmp_path, capsys):
custom_dir = tmp_path / "my-openclaw"
custom_dir.mkdir()
(custom_dir / "todo.json").write_text("{}")
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert not custom_dir.exists()
def test_shows_workspace_details(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
ws = openclaw / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
(ws / "SOUL.md").write_text("# Soul")
args = Namespace(source=None, dry_run=True, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "workspace/" in captured.out
assert "todo.json" in captured.out
def test_handles_multiple_dirs(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
args = Namespace(source=None, dry_run=False, yes=True)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw, clawdbot]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Cleaned up 2" in captured.out
assert not openclaw.exists()
assert not clawdbot.exists()
# ---------------------------------------------------------------------------
# _print_migration_report
# ---------------------------------------------------------------------------