feat(sandbox): add explicit memory history checkpoints for daily notes

Add commit_memory_history() so the host controls when daily-memory
changes are checkpointed into the runtime workspace's local Git repo.
Session ingestion no longer commits implicitly; the host groups one or
many sessions into an explicit boundary with a chosen message.

- Initialize a Git repo in the runtime workspace on case setup
- Validate the configured daily_dir and stage only that path
- Allow empty commits so boundaries are always recorded
- Reset removes the old Git repo and reinitializes for the next case
- Export retains .git for offline history inspection
This commit is contained in:
酱牛肉 2026-08-03 15:46:22 +08:00
parent 7f3ac6a762
commit 3322fe469b
4 changed files with 180 additions and 1 deletions

View file

@ -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()

View file

@ -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:

View file

@ -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 = (

View file

@ -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")