mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-29 19:01:41 +00:00
490 lines
16 KiB
Python
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"
|