diff --git a/sandbox/README.md b/sandbox/README.md index 6cd4ac7a..2d8534a2 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -71,6 +71,7 @@ async def main(): session_id=case.case_id, messages=[{"role": "user", "content": "Remember this."}], ) + await case.commit_memory_history(f"session: {case.case_id}") answer = await case.answer(query="What should you remember?") await case.judge( query="What should you remember?", @@ -136,6 +137,16 @@ under the configured `session_dir`/`dialog_dir` are included. Use case tree. Environment variable names are recorded, but their secret values are never written to the manifest. +The runtime workspace is also a local Git repository. Session ingestion never +commits implicitly: the host chooses checkpoint boundaries by calling +`commit_memory_history(message)`. It may checkpoint after every session or +after any batch of sessions, and controls the commit message. Each checkpoint +commits only the configured `daily_dir`; empty commits are retained as explicit +boundaries. The exported workspace includes `.git`, so the daily-memory +construction history can be inspected offline without a remote or push. A +`reset_case()` removes this repository together with the old runtime workspace +and initializes a fresh repository for the next case. + For an ephemeral Docker workspace, always call `export()` or `export_full()` before `close()`. @@ -150,11 +161,13 @@ uploading the next case's sessions: case = await factory.create_case("session-001") try: await case.ingest_session(session_id="session-001", messages=[...]) + await case.commit_memory_history("session: session-001") first = await case.answer(query="...") await case.export("artifacts/session-001.tar.gz") await case.reset_case("session-002") await case.ingest_session(session_id="session-002", messages=[...]) + await case.commit_memory_history("session: session-002") second = await case.answer(query="...") finally: await case.close() diff --git a/sandbox/docker_sandbox.py b/sandbox/docker_sandbox.py index 84fe3814..bde0b23c 100644 --- a/sandbox/docker_sandbox.py +++ b/sandbox/docker_sandbox.py @@ -23,6 +23,8 @@ SOURCE_ARCHIVE = "/workspace/candidate.tar.gz" EXPORT_ARCHIVE = "/tmp/reme-case-export.tar.gz" RUNTIME_LAYOUT = f"{CASE_ROOT}/runtime-layout.json" _CASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") +_GIT_AUTHOR_NAME = "ReMe Sandbox" +_GIT_AUTHOR_EMAIL = "reme-sandbox@localhost" class Backend(Protocol): @@ -238,6 +240,11 @@ class DockerReMeSandbox: HARNESS_ROOT, ], ) + await self._exec_checked( + "initialize_memory_history", + ["git", "init", "--quiet"], + cwd=self.runtime_workspace, + ) async def _install_source_candidate(self) -> None: assert self.backend is not None @@ -358,6 +365,70 @@ class DockerReMeSandbox: return index_result return result + async def commit_memory_history(self, message: str) -> None: + """Create a host-requested checkpoint containing only daily-memory changes.""" + if not isinstance(message, str) or not message.strip(): + raise ValueError("memory history commit message must be a non-empty string") + async with self._operation_lock: + await self._commit_memory_history(message) + + async def _daily_history_path(self) -> str: + """Return the validated workspace-relative daily directory from the runtime layout.""" + assert self.backend is not None + try: + payload = json.loads((await self.backend.read_file(RUNTIME_LAYOUT)).decode("utf-8")) + except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SandboxCommandError(f"cannot resolve daily history path from runtime layout: {exc}") from exc + + expected_root = PurePosixPath(self.runtime_workspace).relative_to(CASE_ROOT) + configured_paths = payload.get("configured_paths") if isinstance(payload, dict) else None + if not isinstance(configured_paths, dict): + raise SandboxCommandError("sandbox runtime layout has invalid configured_paths") + case_relative = self._validated_workspace_path( + configured_paths.get("daily_dir"), + expected_root, + allow_root=False, + ) + return PurePosixPath(case_relative).relative_to(expected_root).as_posix() + + async def _commit_memory_history(self, message: str) -> None: + """Commit one host-selected boundary while tracking only the configured daily directory.""" + daily_path = await self._daily_history_path() + await self._exec_checked( + "stage_memory_history", + ["git", "add", "-A", "--", daily_path], + cwd=self.runtime_workspace, + ) + diff_result = await self._exec( + "check_memory_history", + ["git", "diff", "--cached", "--quiet", "--", daily_path], + cwd=self.runtime_workspace, + ) + if diff_result.exit_code not in (0, 1): + raise SandboxCommandError( + f"cannot inspect staged daily-memory changes: {_decode(diff_result.stderr)}", + ) + commit_command = [ + "git", + "-c", + f"user.name={_GIT_AUTHOR_NAME}", + "-c", + f"user.email={_GIT_AUTHOR_EMAIL}", + "commit", + "--quiet", + "--allow-empty", + "--only", + "-m", + message, + ] + if diff_result.exit_code == 1: + commit_command.extend(["--", daily_path]) + await self._exec_checked( + "commit_memory_history", + commit_command, + cwd=self.runtime_workspace, + ) + async def answer(self, *, query: str, query_time: str = "") -> JobResult: """Generate an answer with the candidate's agentic_answer job.""" async with self._operation_lock: diff --git a/tests/integration/test_sandbox_longmemeval.py b/tests/integration/test_sandbox_longmemeval.py index 8748645b..7162a804 100644 --- a/tests/integration/test_sandbox_longmemeval.py +++ b/tests/integration/test_sandbox_longmemeval.py @@ -99,6 +99,7 @@ async def test_first_longmemeval_case_analysis_export(tmp_path): date=session_dt.strftime("%Y-%m-%d"), ) assert result.success, result.error or result.answer + await case.commit_memory_history(f"session: {session_id}") answer = await case.answer( query=item["question"], @@ -124,6 +125,8 @@ async def test_first_longmemeval_case_analysis_export(tmp_path): assert layout["configured_paths"]["mem_session_dir"] == "reme_workspace/mem_session" assert any(name.startswith("reme_workspace/daily/") and name.endswith(".md") for name in names) + assert "reme_workspace/.git/HEAD" in names + assert any(name.startswith("reme_workspace/.git/objects/") for name in names) assert any(name.startswith("reme_workspace/mem_session/") for name in names) assert any(name.startswith("reme_workspace/session/dialog/") and name.endswith(".jsonl") for name in names) excluded_prefixes = ( diff --git a/tests/unit/test_sandbox.py b/tests/unit/test_sandbox.py index 7ae92d62..ac196f5e 100644 --- a/tests/unit/test_sandbox.py +++ b/tests/unit/test_sandbox.py @@ -56,6 +56,8 @@ class FakeBackend: for path in list(self.files): if path in command[2:] or path.startswith(roots): del self.files[path] + if command[:2] == ["git", "init"]: + self.files[f"{cwd}/.git/HEAD"] = b"ref: refs/heads/master\n" if command[:2] == ["python3", "-c"] or (len(command) > 2 and command[1:3] == ["-c", command[2]]): stdout = b'{"reme": "/workspace/candidate/reme/__init__.py", "agentscope": "2.0.4.post1"}\n' return FakeExecResult(stdout=stdout) @@ -215,6 +217,84 @@ async def test_image_candidate_skips_source_upload_and_runs_jobs(tmp_path): assert "--exclude=reme_workspace/session" not in export_command +@pytest.mark.asyncio +async def test_host_selects_when_to_commit_configured_daily_directory(): + """Several ingested sessions can be grouped into one explicit host checkpoint.""" + workspace = FakeWorkspace("case-1", "candidate:test", {}) + factory = DockerReMeSandboxFactory( + ImageCandidate("candidate:test"), + workspace_builder=lambda *_args: workspace, + ) + case = await factory.create_case("case-1") + + first = await case.ingest_session(messages=[], session_id="session-001", update_index=False) + second = await case.ingest_session(messages=[], session_id="session-002", update_index=False) + + assert first.success is second.success is True + commands = workspace.backend.commands + assert not any("commit" in command for command, _, _ in commands) + + await case.commit_memory_history("sessions: session-001, session-002") + + assert (["git", "init", "--quiet"], case.runtime_workspace, case.command_timeout) in commands + assert (["git", "add", "-A", "--", "daily"], case.runtime_workspace, case.command_timeout) in commands + commit = next(command for command, cwd, _ in commands if "commit" in command and cwd == case.runtime_workspace) + assert "--allow-empty" in commit + assert "--only" in commit + assert commit[-2:] == ["-m", "sessions: session-001, session-002"] + assert all(path not in commit for path in ("metadata", "session", "resource", ".")) + + +@pytest.mark.asyncio +async def test_memory_history_uses_resolved_custom_daily_directory(): + """Git pathspecs follow the candidate's validated daily_dir configuration.""" + workspace = FakeWorkspace("case-1", "candidate:test", {}) + factory = DockerReMeSandboxFactory( + ImageCandidate("candidate:test"), + workspace_builder=lambda *_args: workspace, + ) + case = await factory.create_case("case-1") + workspace.backend.files["/workspace/case/runtime-layout.json"] = json.dumps( + { + "workspace_root": "reme_workspace", + "configured_paths": {"daily_dir": "reme_workspace/memories/daily-notes"}, + "analysis_excludes": [], + }, + ).encode() + + assert await case._daily_history_path() == "memories/daily-notes" # pylint: disable=protected-access + + +@pytest.mark.asyncio +async def test_failed_ingest_session_does_not_create_history_commit(): + """Failed memory construction must not be represented as a successful session boundary.""" + + class FailingJobBackend(FakeBackend): + """Return a failed response for every emulated ReMe job.""" + + async def exec_shell(self, command, *, cwd=None, timeout=None): + result = await super().exec_shell(command, cwd=cwd, timeout=timeout) + if "/workspace/harness/worker.py" in command: + response_path = command[command.index("--response") + 1] + self.files[response_path] = json.dumps( + {"success": False, "answer": "failed", "metadata": {}, "error": "failed"}, + ).encode() + return result + + backend = FailingJobBackend() + workspace = FakeWorkspace("case-1", "candidate:test", {}, backend=backend) + factory = DockerReMeSandboxFactory( + ImageCandidate("candidate:test"), + workspace_builder=lambda *_args: workspace, + ) + case = await factory.create_case("case-1") + + result = await case.ingest_session(messages=[], session_id="session-001", update_index=False) + + assert result.success is False + assert not any("commit" in command for command, _, _ in backend.commands) + + @pytest.mark.asyncio async def test_one_container_can_reset_and_run_another_case(tmp_path): """Reset removes case state while retaining the installed candidate and worker.""" @@ -234,6 +314,8 @@ async def test_one_container_can_reset_and_run_another_case(tmp_path): backend = workspaces[0].backend worker_bytes = backend.files["/workspace/harness/worker.py"] backend.files["/workspace/candidate/reme/__init__.py"] = b"candidate-code" + old_git_object = "/workspace/case/reme_workspace/.git/objects/old-case-object" + backend.files[old_git_object] = b"old case history" assert any(path.startswith("/workspace/case/") for path in backend.files) await case.reset_case("case-2") @@ -242,8 +324,18 @@ async def test_one_container_can_reset_and_run_another_case(tmp_path): assert len(workspaces) == 1 assert backend.files["/workspace/harness/worker.py"] == worker_bytes assert backend.files["/workspace/candidate/reme/__init__.py"] == b"candidate-code" - assert not any(path.startswith("/workspace/case/") for path in backend.files) + assert old_git_object not in backend.files + assert backend.files["/workspace/case/reme_workspace/.git/HEAD"].startswith(b"ref:") + assert not any(path.startswith("/workspace/case/results/") for path in backend.files) assert "/tmp/reme-case-export.tar.gz" not in backend.files + git_init_positions = [ + index for index, (command, _, _) in enumerate(backend.commands) if command == ["git", "init", "--quiet"] + ] + clear_position = next( + index for index, (command, _, _) in enumerate(backend.commands) if command[:2] == ["rm", "-rf"] + ) + assert len(git_init_positions) == 2 + assert git_init_positions[0] < clear_position < git_init_positions[1] result = await case.run_job("agentic_answer", {"query": "second"}) await case.export(tmp_path / "case-2.tar.gz")