eigent/backend/tests/app/workspace_git/test_lifecycle.py
4pmtong 1b4820e54d
Some checks are pending
Pre-commit / pre-commit (push) Waiting to run
Test / Run Web + Local Brain Smoke (push) Waiting to run
Test / Run Frontend Guardrails (push) Waiting to run
Test / Run Python Tests (push) Waiting to run
feat: run tasks on durable project git checkouts
2026-08-21 22:32:52 +08:00

490 lines
16 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app import artifacts
from app.run_context import RunContext
from app.run_journal import RunEventDraft, SQLiteRunJournal
from app.workspace_git import (
ContentRepositoryService,
GitBackend,
WorkforceGitService,
WorkspaceGitCoordinator,
WorkspaceGitLifecycle,
WorkspaceMutationService,
)
@pytest.fixture
def journal(tmp_path):
with SQLiteRunJournal(tmp_path / "run-journal.sqlite3") as value:
yield value
def _context(space: Path) -> RunContext:
return RunContext(
space_id="space-1",
project_id="project-1",
run_id="run-1",
task_id="task-1",
email="user@example.com",
user_id="user-1",
working_directory=space,
task_output_root=space,
camel_log_dir=space / ".logs",
binding_source="test",
workdir_mode="direct-write",
browser_port=9222,
)
def test_terminal_run_promotes_refreshes_and_archives(
tmp_path, journal, monkeypatch
):
hooks = tmp_path / "empty-hooks"
hooks.mkdir()
git = GitBackend(hooks_path=hooks)
state_root = tmp_path / "state"
content = ContentRepositoryService(
journal,
state_root=state_root,
git_backend=git,
)
coordinator = WorkspaceGitCoordinator(
journal,
state_root=state_root,
git_backend=git,
)
mutations = WorkspaceMutationService(
journal,
state_root=state_root,
coordinator=coordinator,
primary_checkout_enabled=False,
)
lifecycle = WorkspaceGitLifecycle(
journal,
state_root=state_root,
coordinator=coordinator,
)
space = tmp_path / "space"
space.mkdir()
content.bootstrap(
space_id="space-1",
space_root=space,
allow_init=True,
eigent_owned_space=True,
)
seed = space / "seed.txt"
seed.write_text("seed", encoding="utf-8")
git.commit_paths(space, (seed,), message="seed")
journal.ensure_run(run_id="run-1", project_id="project-1")
admission = coordinator.admit_run(
space_id="space-1",
project_id="project-1",
run_id="run-1",
)
assert admission is not None
prepared = mutations.prepare_file_write(
context=_context(space),
filename="generated.txt",
operation_request_id="tool-call-1",
actor_id="agent-1",
trigger="filesystem.write",
)
assert prepared is not None
active_ref = prepared.workspace.run.run_ref
run_worktree = prepared.workspace.run_worktree
prepared.target_path.write_text("project continuity", encoding="utf-8")
mutations.complete_file_write(
prepared,
operation_request_id="tool-call-1",
actor_id="agent-1",
trigger="filesystem.write",
)
agent_before = journal.get_git_agent_workspace("run-1", "agent-1")
assert agent_before is not None
agent_worktree = Path(agent_before.worktree_path)
agent_ref = agent_before.agent_ref
agent_head = agent_before.head_commit
original_complete_apply = journal.complete_project_auto_apply
crashed = False
def crash_after_files_before_sqlite(**kwargs):
nonlocal crashed
if not crashed:
crashed = True
raise RuntimeError("simulated crash after Space file projection")
return original_complete_apply(**kwargs)
monkeypatch.setattr(
journal, "complete_project_auto_apply", crash_after_files_before_sqlite
)
interrupted_apply = lifecycle.prepare_successful_run("run-1")
assert interrupted_apply.outcome == "prepared_project"
assert (space / "generated.txt").read_text() == "project continuity"
interrupted_project = journal.get_project_git_state("project-1")
assert interrupted_project is not None
assert interrupted_project.pending_apply is True
monkeypatch.setattr(
journal, "complete_project_auto_apply", original_complete_apply
)
prepared_result = lifecycle.prepare_successful_run("run-1")
assert prepared_result.outcome == "prepared_space"
assert (space / "generated.txt").read_text() == "project continuity"
prepared_project = journal.get_project_git_state("project-1")
assert prepared_project is not None
assert prepared_project.pending_apply is False
snapshot = SimpleNamespace(
task_id="run-1",
project_id="project-1",
space_id="space-1",
user_id="user-1",
task_output_root=str(tmp_path / "missing-output-root"),
working_directory=str(space),
# Deliberately exclude every mtime. The committed Git delta remains
# authoritative and must still produce the Artifact manifest.
task_start_time=10_000_000_000,
artifact_manifest=None,
)
resolver = MagicMock()
resolver.store.find_snapshot.return_value = ("user@example.com", snapshot)
monkeypatch.setattr(artifacts, "get_workspace_resolver", lambda: resolver)
canonical_run = journal.get_run("run-1")
assert canonical_run is not None
manifest = artifacts.finalize_run_artifacts(journal, canonical_run)
generated_artifact = next(
item
for item in manifest.payload["artifacts"]
if item["relativePath"] == "generated.txt"
)
assert generated_artifact["path"] == str(
(space / "generated.txt").resolve()
)
assert generated_artifact["uploadPolicy"] == "agent_generated"
journal.append_event(
"run-1",
RunEventDraft(
event_id="run-1-completed",
event_type="run.completed",
payload={"reason": "test"},
),
)
result = lifecycle.finalize_run("run-1")
replay = lifecycle.finalize_run("run-1")
assert result.outcome == "archived"
assert replay == result
writer = journal.get_workspace_writer_request("workspace-writer:run-1")
assert writer is not None and writer.status == "released"
run = journal.get_run_git_materialization("run-1")
project = journal.get_project_git_state("project-1")
assert run is not None and project is not None
assert run.materialization_state == "archived"
assert run.worktree_path is None
assert run.run_ref == result.archive_ref
assert project.integration_head == run.promoted_commit
assert project.projected_head == run.promoted_commit
assert (Path(project.worktree_path) / "generated.txt").read_text() == (
"project continuity"
)
assert not run_worktree.exists()
assert active_ref is not None
assert git.ref_oid(space, active_ref) is None
assert result.archive_ref is not None
assert git.ref_oid(space, result.archive_ref) == run.promoted_commit
agent_after = journal.get_git_agent_workspace("run-1", "agent-1")
assert agent_after is not None
assert agent_after.state == "archived"
assert agent_after.lease_token is None
assert not agent_worktree.exists()
assert git.ref_oid(space, agent_ref) is None
assert agent_head is not None
archive_operation = journal.get_git_operation(
agent_after.last_operation_id or ""
)
assert archive_operation is not None
assert archive_operation.status == "completed"
assert archive_operation.result is not None
agent_archive_ref = archive_operation.result["archive_ref"]
assert git.ref_oid(space, agent_archive_ref) == agent_head
def test_eigent_space_auto_apply_never_overwrites_a_user_edit(
tmp_path, journal
):
hooks = tmp_path / "empty-hooks"
hooks.mkdir()
git = GitBackend(hooks_path=hooks)
state_root = tmp_path / "state"
content = ContentRepositoryService(
journal, state_root=state_root, git_backend=git
)
coordinator = WorkspaceGitCoordinator(
journal, state_root=state_root, git_backend=git
)
mutations = WorkspaceMutationService(
journal,
state_root=state_root,
coordinator=coordinator,
primary_checkout_enabled=False,
)
lifecycle = WorkspaceGitLifecycle(
journal, state_root=state_root, coordinator=coordinator
)
space = tmp_path / "space"
space.mkdir()
content.bootstrap(
space_id="space-1",
space_root=space,
allow_init=True,
eigent_owned_space=True,
)
seed = space / "seed.txt"
seed.write_text("seed", encoding="utf-8")
git.commit_paths(space, (seed,), message="seed")
journal.ensure_run(run_id="run-1", project_id="project-1")
assert (
coordinator.admit_run(
space_id="space-1", project_id="project-1", run_id="run-1"
)
is not None
)
prepared = mutations.prepare_file_write(
context=_context(space),
filename="generated.txt",
operation_request_id="tool-call-conflict",
actor_id="agent-1",
trigger="filesystem.write",
)
assert prepared is not None
prepared.target_path.write_text("agent result", encoding="utf-8")
mutations.complete_file_write(
prepared,
operation_request_id="tool-call-conflict",
actor_id="agent-1",
trigger="filesystem.write",
)
visible = space / "generated.txt"
visible.write_text("user edit", encoding="utf-8")
result = lifecycle.prepare_successful_run("run-1")
assert result.outcome == "prepared_project"
assert visible.read_text(encoding="utf-8") == "user edit"
project = journal.get_project_git_state("project-1")
assert project is not None
assert project.pending_apply is True
change_set = journal.get_git_change_set_for_run("run-1")
assert change_set is not None
assert change_set.state == "checkpointed"
def test_terminal_run_waits_for_unfinished_change_set_item(tmp_path, journal):
hooks = tmp_path / "empty-hooks"
hooks.mkdir()
git = GitBackend(hooks_path=hooks)
state_root = tmp_path / "state"
content = ContentRepositoryService(
journal,
state_root=state_root,
git_backend=git,
)
coordinator = WorkspaceGitCoordinator(
journal,
state_root=state_root,
git_backend=git,
)
mutations = WorkspaceMutationService(
journal,
state_root=state_root,
coordinator=coordinator,
primary_checkout_enabled=False,
)
lifecycle = WorkspaceGitLifecycle(
journal,
state_root=state_root,
coordinator=coordinator,
)
space = tmp_path / "space"
space.mkdir()
content.bootstrap(
space_id="space-1",
space_root=space,
allow_init=True,
)
seed = space / "seed.txt"
seed.write_text("seed", encoding="utf-8")
git.commit_paths(space, (seed,), message="seed")
journal.ensure_run(run_id="run-1", project_id="project-1")
assert (
coordinator.admit_run(
space_id="space-1",
project_id="project-1",
run_id="run-1",
)
is not None
)
prepared = mutations.prepare_file_write(
context=_context(space),
filename="generated.txt",
operation_request_id="unfinished-write",
actor_id="agent-1",
trigger="filesystem.write",
)
assert prepared is not None
journal.append_event(
"run-1",
RunEventDraft(
event_id="run-1-completed",
event_type="run.completed",
payload={},
),
)
result = lifecycle.finalize_run("run-1")
assert result.outcome == "deferred_mutation"
run = journal.get_run_git_materialization("run-1")
assert run is not None
assert run.materialization_state == "materialized"
assert prepared.workspace.run_worktree.exists()
def test_terminal_unmaterialized_run_creates_no_archive_ref(tmp_path, journal):
lifecycle = WorkspaceGitLifecycle(journal, state_root=tmp_path / "state")
journal.ensure_run(run_id="run-1", project_id="project-1")
journal.append_event(
"run-1",
RunEventDraft(
event_id="run-1-completed",
event_type="run.completed",
payload={},
),
)
result = lifecycle.finalize_run("run-1")
assert result.outcome == "not_materialized"
assert result.archive_ref is None
def test_noop_agent_archive_recovers_after_git_before_sqlite_crash(
tmp_path,
journal,
monkeypatch,
):
hooks = tmp_path / "empty-hooks"
hooks.mkdir()
git = GitBackend(hooks_path=hooks)
state_root = tmp_path / "state"
content = ContentRepositoryService(
journal,
state_root=state_root,
git_backend=git,
)
coordinator = WorkspaceGitCoordinator(
journal,
state_root=state_root,
git_backend=git,
)
workforce = WorkforceGitService(
journal,
state_root=state_root,
coordinator=coordinator,
)
lifecycle = WorkspaceGitLifecycle(
journal,
state_root=state_root,
coordinator=coordinator,
workforce=workforce,
)
space = tmp_path / "space"
space.mkdir()
content.bootstrap(space_id="space-1", space_root=space, allow_init=True)
seed = space / "seed.txt"
seed.write_text("seed", encoding="utf-8")
git.commit_paths(space, (seed,), message="seed")
journal.ensure_run(run_id="run-1", project_id="project-1")
admission = coordinator.admit_run(
space_id="space-1",
project_id="project-1",
run_id="run-1",
)
assert admission is not None
workspace = coordinator.ensure_run_materialized(
run_id="run-1",
operation_request_id="materialize-run",
expected_repo_state_digest=git.repo_state_token(space).digest,
expected_project_version=admission.project.version,
expected_project_head=admission.project.integration_head,
)
agent = workforce.ensure_agent_workspace(
run_workspace=workspace,
agent_id="reader",
operation_request_id="materialize-agent",
)
agent_worktree = agent.agent_worktree
workforce.release_workspace(agent)
journal.append_event(
"run-1",
RunEventDraft(
event_id="run-1-completed",
event_type="run.completed",
payload={},
),
)
original_transition = journal.transition_git_agent_workspace
crashed = False
def crash_before_archive_state(*args, **kwargs):
nonlocal crashed
if kwargs.get("state") == "archived" and not crashed:
crashed = True
raise RuntimeError("simulated crash after Agent Git archive")
return original_transition(*args, **kwargs)
monkeypatch.setattr(
journal,
"transition_git_agent_workspace",
crash_before_archive_state,
)
with pytest.raises(RuntimeError, match="simulated crash"):
lifecycle.finalize_run("run-1")
monkeypatch.setattr(
journal,
"transition_git_agent_workspace",
original_transition,
)
partial = journal.get_git_agent_workspace("run-1", "reader")
assert partial is not None and partial.state == "ready"
assert not agent_worktree.exists()
recovered = lifecycle.finalize_run("run-1")
assert recovered.outcome == "archived"
final_agent = journal.get_git_agent_workspace("run-1", "reader")
assert final_agent is not None and final_agent.state == "archived"