rewrite phase 1
This commit is contained in:
+597
-42
@@ -1,44 +1,586 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from agentci.domain.models import Workflow, WorkflowKind
|
||||
from agentci.domain.state_machine import JobState
|
||||
import pytest
|
||||
|
||||
from agentci.engine.events import (
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobFailed,
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
ServiceRestarted,
|
||||
)
|
||||
from agentci.engine.events import (
|
||||
JobRejected as RejectedEvent,
|
||||
)
|
||||
from agentci.engine.model import (
|
||||
IncomingCommand,
|
||||
Job,
|
||||
JobKind,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
Task,
|
||||
TaskKind,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
)
|
||||
from agentci.engine.reducer import render_job_comment
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.gitea import CommentInfo
|
||||
from agentci.worker import Worker, _safe_error
|
||||
from agentci.workflows.render import JobRejected
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, workflow=None) -> None:
|
||||
class FakeRepository:
|
||||
def __init__(
|
||||
self,
|
||||
value: Job | None = None,
|
||||
*,
|
||||
workflow: Workflow | None = None,
|
||||
) -> None:
|
||||
self.job = value
|
||||
self.workflow = workflow
|
||||
self.tasks: list[Task] = []
|
||||
self.claimed_queues: list[QueueName] = []
|
||||
self.completed: list[int] = []
|
||||
self.retried: list[tuple[int, int, str]] = []
|
||||
self.events: list[tuple[str, object]] = []
|
||||
self.operations: list[str] = []
|
||||
self.running: list[Job] = []
|
||||
self.failed_workflows: list[str] = []
|
||||
self.complete_stop: asyncio.Event | None = None
|
||||
|
||||
async def get_workflow(self, _workflow_id):
|
||||
async def claim_task(self, queue: QueueName) -> Task | None:
|
||||
self.claimed_queues.append(queue)
|
||||
return self.tasks.pop(0) if self.tasks else None
|
||||
|
||||
async def complete_task(self, task_id: int) -> None:
|
||||
self.completed.append(task_id)
|
||||
if self.complete_stop is not None:
|
||||
self.complete_stop.set()
|
||||
|
||||
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
|
||||
self.retried.append((task_id, attempts, error))
|
||||
|
||||
async def get_job(self, _job_id: str) -> Job | None:
|
||||
return self.job
|
||||
|
||||
async def apply(self, event_id: str, event: object) -> SimpleNamespace:
|
||||
self.operations.append(f"apply:{event_id}")
|
||||
self.events.append((event_id, event))
|
||||
if isinstance(event, JobStarted) and self.job is not None:
|
||||
self.job = replace(self.job, status=JobStatus.RUNNING, stage="starting")
|
||||
elif isinstance(event, CommentLinked) and self.job is not None:
|
||||
self.job = replace(self.job, accepted_comment_id=event.comment_id)
|
||||
return SimpleNamespace(job=self.job)
|
||||
|
||||
async def recover_tasks(self) -> None:
|
||||
self.operations.append("recover_tasks")
|
||||
|
||||
async def running_jobs(self) -> list[Job]:
|
||||
self.operations.append("running_jobs")
|
||||
return self.running
|
||||
|
||||
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
|
||||
return self.workflow
|
||||
|
||||
async def fail_job_workflow(self, job_id: str) -> None:
|
||||
self.failed_workflows.append(job_id)
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, *, permitted: bool = True) -> None:
|
||||
self.permitted = permitted
|
||||
self.permission_calls: list[tuple[str, str, str]] = []
|
||||
self.update_results: list[bool] = [True]
|
||||
self.update_calls: list[tuple[str, str, int, str]] = []
|
||||
self.comments: list[CommentInfo] = []
|
||||
self.issue_comment_calls: list[tuple[str, str, int]] = []
|
||||
self.created_comment_id = 42
|
||||
self.create_calls: list[tuple[str, str, int, str]] = []
|
||||
|
||||
async def has_write_permission(self, owner: str, repo: str, user: str) -> bool:
|
||||
self.permission_calls.append((owner, repo, user))
|
||||
return self.permitted
|
||||
|
||||
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
|
||||
self.update_calls.append((owner, repo, comment_id, body))
|
||||
if len(self.update_results) > 1:
|
||||
return self.update_results.pop(0)
|
||||
return self.update_results[0]
|
||||
|
||||
async def issue_comments(self, owner: str, repo: str, issue: int) -> list[CommentInfo]:
|
||||
self.issue_comment_calls.append((owner, repo, issue))
|
||||
return self.comments
|
||||
|
||||
async def create_comment(self, owner: str, repo: str, issue: int, body: str) -> int:
|
||||
self.create_calls.append((owner, repo, issue, body))
|
||||
return self.created_comment_id
|
||||
|
||||
|
||||
class FakeOpenCode:
|
||||
def __init__(self) -> None:
|
||||
self.aborted = set()
|
||||
def __init__(self, ready: list[bool] | None = None) -> None:
|
||||
self.ready_results = ready or [True]
|
||||
self.ready_calls = 0
|
||||
self.aborted: list[tuple[str, Path]] = []
|
||||
|
||||
async def abort(self, session_id, workspace):
|
||||
self.aborted.add((session_id, workspace))
|
||||
async def ready(self) -> bool:
|
||||
self.ready_calls += 1
|
||||
if len(self.ready_results) > 1:
|
||||
return self.ready_results.pop(0)
|
||||
return self.ready_results[0]
|
||||
|
||||
async def abort(self, session_id: str, workspace: Path) -> None:
|
||||
self.aborted.append((session_id, workspace))
|
||||
|
||||
|
||||
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
|
||||
def job(
|
||||
*,
|
||||
job_id: str = "job",
|
||||
status: JobStatus = JobStatus.RECEIVED,
|
||||
workflow_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
accepted_comment_id: int | None = None,
|
||||
) -> Job:
|
||||
return Job(
|
||||
id=job_id,
|
||||
target_key="org/repo:issue:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=1,
|
||||
delivery_id=f"delivery-{job_id}",
|
||||
receive_sequence=1,
|
||||
command_body="/agent plan",
|
||||
kind=JobKind.PLAN if status is not JobStatus.RECEIVED else None,
|
||||
status=status,
|
||||
stage=status.value,
|
||||
workflow_id=workflow_id,
|
||||
runtime_session_id=session_id,
|
||||
accepted_comment_id=accepted_comment_id,
|
||||
)
|
||||
|
||||
|
||||
def task(
|
||||
kind: TaskKind = TaskKind.EXECUTE,
|
||||
queue: QueueName = QueueName.JOBS,
|
||||
*,
|
||||
task_id: int = 7,
|
||||
) -> Task:
|
||||
return Task(task_id, "job", "source", kind, queue, 2)
|
||||
|
||||
|
||||
def make_worker(
|
||||
tmp_path: Path,
|
||||
repository: FakeRepository | Repository,
|
||||
*,
|
||||
gitea: FakeGitea | None = None,
|
||||
opencode: FakeOpenCode | None = None,
|
||||
max_concurrent_jobs: int = 2,
|
||||
) -> Worker:
|
||||
return Worker(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
state_machine=SimpleNamespace(), # type: ignore[arg-type]
|
||||
gitea=SimpleNamespace(), # type: ignore[arg-type]
|
||||
opencode=opencode, # type: ignore[arg-type]
|
||||
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
|
||||
repository=repository, # type: ignore[arg-type]
|
||||
gitea=gitea or FakeGitea(), # type: ignore[arg-type]
|
||||
opencode=opencode or FakeOpenCode(), # type: ignore[arg-type]
|
||||
services=SimpleNamespace(), # type: ignore[arg-type]
|
||||
poll_seconds=1,
|
||||
max_concurrent_jobs=2,
|
||||
max_concurrent_jobs=max_concurrent_jobs,
|
||||
workspaces_dir=tmp_path,
|
||||
bot_username="agentci",
|
||||
)
|
||||
|
||||
|
||||
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
async def test_loop_completes_successful_task(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [queued_task]
|
||||
stop = asyncio.Event()
|
||||
handled: list[Task] = []
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def handle(current: Task) -> None:
|
||||
handled.append(current)
|
||||
stop.set()
|
||||
|
||||
monkeypatch.setattr(value, "_handle", handle)
|
||||
|
||||
await value._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert handled == [queued_task]
|
||||
assert repository.completed == [queued_task.id]
|
||||
assert repository.retried == []
|
||||
|
||||
|
||||
async def test_loop_retries_failed_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [queued_task]
|
||||
stop = asyncio.Event()
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def fail(_current: Task) -> None:
|
||||
stop.set()
|
||||
raise RuntimeError("provider\nfailed")
|
||||
|
||||
monkeypatch.setattr(value, "_handle", fail)
|
||||
|
||||
await value._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert repository.completed == []
|
||||
assert repository.retried == [
|
||||
(queued_task.id, queued_task.attempts, "RuntimeError: provider failed")
|
||||
]
|
||||
|
||||
|
||||
async def test_loop_propagates_cancellation_without_rescheduling(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [queued_task]
|
||||
stop = asyncio.Event()
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def cancel(_current: Task) -> None:
|
||||
stop.set()
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(value, "_handle", cancel)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await value._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert repository.completed == []
|
||||
assert repository.retried == []
|
||||
|
||||
|
||||
async def test_loop_completes_task_whose_job_no_longer_exists(tmp_path: Path) -> None:
|
||||
repository = FakeRepository(None)
|
||||
missing_job_task = task(queue=QueueName.CONTROL)
|
||||
repository.tasks = [missing_job_task]
|
||||
stop = asyncio.Event()
|
||||
repository.complete_stop = stop
|
||||
|
||||
await make_worker(tmp_path, repository)._loop(QueueName.CONTROL, stop)
|
||||
|
||||
assert repository.completed == [missing_job_task.id]
|
||||
assert repository.retried == []
|
||||
|
||||
|
||||
async def test_jobs_queue_waits_for_provider_readiness(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repository = FakeRepository()
|
||||
queued_task = task()
|
||||
repository.tasks = [queued_task]
|
||||
opencode = FakeOpenCode([False, True])
|
||||
stop = asyncio.Event()
|
||||
waits: list[asyncio.Event] = []
|
||||
value = make_worker(tmp_path, repository, opencode=opencode)
|
||||
|
||||
async def wait(current_stop: asyncio.Event) -> None:
|
||||
waits.append(current_stop)
|
||||
|
||||
async def handle(_current: Task) -> None:
|
||||
stop.set()
|
||||
|
||||
monkeypatch.setattr(value, "_wait", wait)
|
||||
monkeypatch.setattr(value, "_handle", handle)
|
||||
|
||||
await value._loop(QueueName.JOBS, stop)
|
||||
|
||||
assert opencode.ready_calls == 2
|
||||
assert waits == [stop]
|
||||
assert repository.claimed_queues == [QueueName.JOBS]
|
||||
assert repository.completed == [queued_task.id]
|
||||
|
||||
|
||||
async def test_run_recovers_before_starting_configured_consumers(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
value = make_worker(tmp_path, FakeRepository(), max_concurrent_jobs=3)
|
||||
calls: list[str | QueueName] = []
|
||||
|
||||
async def recover() -> None:
|
||||
calls.append("recover")
|
||||
|
||||
async def loop(queue: QueueName, _stop: asyncio.Event) -> None:
|
||||
calls.append(queue)
|
||||
|
||||
monkeypatch.setattr(value, "_recover", recover)
|
||||
monkeypatch.setattr(value, "_loop", loop)
|
||||
|
||||
await value.run(asyncio.Event())
|
||||
|
||||
assert calls[0] == "recover"
|
||||
assert calls.count(QueueName.CONTROL) == 1
|
||||
assert calls.count(QueueName.JOBS) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permitted", "event_type", "suffix"),
|
||||
[
|
||||
(True, PermissionGranted, "permission-granted"),
|
||||
(False, PermissionDenied, "permission-denied"),
|
||||
],
|
||||
)
|
||||
async def test_authorize_records_permission_outcome(
|
||||
tmp_path: Path,
|
||||
permitted: bool,
|
||||
event_type: type[PermissionGranted] | type[PermissionDenied],
|
||||
suffix: str,
|
||||
) -> None:
|
||||
current = job()
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea(permitted=permitted)
|
||||
authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL)
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current)
|
||||
|
||||
assert gitea.permission_calls == [("org", "repo", "alice")]
|
||||
assert repository.events[0][0] == f"task:{authorize.id}:{suffix}"
|
||||
assert isinstance(repository.events[0][1], event_type)
|
||||
|
||||
|
||||
async def test_authorize_ignores_job_after_received_status(tmp_path: Path) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._authorize(
|
||||
task(TaskKind.AUTHORIZE, QueueName.CONTROL), current
|
||||
)
|
||||
|
||||
assert gitea.permission_calls == []
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
async def test_authorize_integrates_with_real_repository(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||
await repository.initialize()
|
||||
accepted = await repository.accept(
|
||||
IncomingCommand(
|
||||
delivery_id="delivery-real",
|
||||
comment_id=1,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
body="/agent plan write tests",
|
||||
)
|
||||
)
|
||||
authorize = await repository.claim_task(QueueName.CONTROL)
|
||||
assert authorize is not None
|
||||
assert authorize.kind is TaskKind.AUTHORIZE
|
||||
|
||||
await make_worker(tmp_path, repository)._handle(authorize)
|
||||
|
||||
persisted = await repository.get_job(accepted.job.id)
|
||||
assert persisted is not None
|
||||
assert persisted.status is JobStatus.QUEUED
|
||||
assert persisted.kind is JobKind.PLAN
|
||||
assert persisted.message == "write tests"
|
||||
execute = await repository.claim_task(QueueName.JOBS)
|
||||
assert execute is not None
|
||||
assert execute.kind is TaskKind.EXECUTE
|
||||
|
||||
|
||||
async def test_execute_completes_with_workflow_comment(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
value = make_worker(tmp_path, repository)
|
||||
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
return "final workflow body"
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
execute = task()
|
||||
|
||||
await value._execute(execute, current)
|
||||
|
||||
assert [event_id for event_id, _ in repository.events] == [
|
||||
f"task:{execute.id}:started",
|
||||
f"task:{execute.id}:completed",
|
||||
]
|
||||
assert isinstance(repository.events[0][1], JobStarted)
|
||||
completed = repository.events[1][1]
|
||||
assert isinstance(completed, JobCompleted)
|
||||
assert completed.comment_body == "final workflow body"
|
||||
|
||||
|
||||
async def test_execute_records_expected_rejection(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
raise JobRejected("pull request is closed")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
rejected = repository.events[-1][1]
|
||||
assert isinstance(rejected, RejectedEvent)
|
||||
assert rejected.reason == "pull request is closed"
|
||||
|
||||
|
||||
async def test_execute_records_failure_at_latest_persisted_stage(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
assert repository.job is not None
|
||||
repository.job = replace(repository.job, stage="cloning")
|
||||
raise RuntimeError("provider\nfailed")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
failed = repository.events[-1][1]
|
||||
assert isinstance(failed, JobFailed)
|
||||
assert failed.stage == "cloning"
|
||||
assert failed.error == "RuntimeError: provider failed"
|
||||
|
||||
|
||||
async def test_execute_marks_running_job_interrupted_after_restart(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
current = job(status=JobStatus.RUNNING)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
pytest.fail("running jobs must not be dispatched again")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", unexpected_dispatch)
|
||||
execute = task()
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(execute, current)
|
||||
|
||||
assert repository.events[0][0] == f"task:{execute.id}:interrupted"
|
||||
assert isinstance(repository.events[0][1], ServiceRestarted)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [JobStatus.RECEIVED, JobStatus.SUCCEEDED])
|
||||
async def test_execute_ignores_ineligible_status(tmp_path: Path, status: JobStatus) -> None:
|
||||
current = job(status=status)
|
||||
repository = FakeRepository(current)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
async def test_reconcile_updates_linked_comment_in_place(tmp_path: Path) -> None:
|
||||
current = job(status=JobStatus.QUEUED, accepted_comment_id=19)
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(
|
||||
task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), current
|
||||
)
|
||||
|
||||
assert gitea.update_calls == [("org", "repo", 19, render_job_comment(current))]
|
||||
assert gitea.issue_comment_calls == []
|
||||
assert gitea.create_calls == []
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
async def test_reconcile_finds_oldest_matching_bot_comment_and_links_it(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
current = job(status=JobStatus.QUEUED, accepted_comment_id=99)
|
||||
marker = f"<!-- agentci:job id={current.id} -->"
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
gitea.update_results = [False, True]
|
||||
gitea.comments = [
|
||||
CommentInfo(8, "agentci", f"{marker}\nnewer", ""),
|
||||
CommentInfo(3, "AgentCI", f"{marker}\nolder", ""),
|
||||
CommentInfo(1, "alice", f"{marker}\nnot the bot", ""),
|
||||
CommentInfo(2, "agentci", f"prefix {marker}", ""),
|
||||
]
|
||||
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
|
||||
|
||||
assert gitea.issue_comment_calls == [("org", "repo", 1)]
|
||||
assert gitea.create_calls == []
|
||||
assert [call[2] for call in gitea.update_calls] == [99, 3]
|
||||
assert repository.events[0][0] == f"task:{reconcile.id}:comment:3"
|
||||
linked = repository.events[0][1]
|
||||
assert isinstance(linked, CommentLinked)
|
||||
assert linked.comment_id == 3
|
||||
|
||||
|
||||
async def test_reconcile_creates_links_and_populates_missing_comment(tmp_path: Path) -> None:
|
||||
current = job(status=JobStatus.SUCCEEDED)
|
||||
repository = FakeRepository(current)
|
||||
gitea = FakeGitea()
|
||||
gitea.comments = [CommentInfo(1, "alice", "unrelated", "")]
|
||||
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||
|
||||
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
|
||||
|
||||
body = render_job_comment(current)
|
||||
assert gitea.create_calls == [("org", "repo", 1, body)]
|
||||
assert gitea.update_calls == [("org", "repo", 42, body)]
|
||||
assert repository.events[0][0] == f"task:{reconcile.id}:comment:42"
|
||||
linked = repository.events[0][1]
|
||||
assert isinstance(linked, CommentLinked)
|
||||
assert linked.comment_id == 42
|
||||
|
||||
|
||||
async def test_recovery_resets_tasks_before_interrupting_running_jobs(tmp_path: Path) -> None:
|
||||
repository = FakeRepository()
|
||||
repository.running = [
|
||||
job(job_id="first", status=JobStatus.RUNNING),
|
||||
job(job_id="second", status=JobStatus.RUNNING),
|
||||
]
|
||||
|
||||
await make_worker(tmp_path, repository)._recover()
|
||||
|
||||
assert repository.operations == [
|
||||
"recover_tasks",
|
||||
"running_jobs",
|
||||
"apply:recovery:first:service-restarted",
|
||||
"apply:recovery:second:service-restarted",
|
||||
]
|
||||
assert all(isinstance(event, ServiceRestarted) for _, event in repository.events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("primary", "reviewer", "expected"),
|
||||
[
|
||||
("primary", "reviewer", {"primary", "reviewer"}),
|
||||
("same-session", "same-session", {"same-session"}),
|
||||
],
|
||||
)
|
||||
async def test_abort_collects_and_deduplicates_workflow_sessions(
|
||||
tmp_path: Path,
|
||||
primary: str,
|
||||
reviewer: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
workspace = tmp_path / "workflow" / "repo"
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
@@ -48,46 +590,59 @@ async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
issue_number=1,
|
||||
workspace_path=workspace,
|
||||
base_sha="base",
|
||||
primary_session_id="primary",
|
||||
reviewer_session_id="reviewer",
|
||||
primary_session_id=primary,
|
||||
reviewer_session_id=reviewer,
|
||||
)
|
||||
opencode = FakeOpenCode()
|
||||
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
|
||||
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
|
||||
cast(JobState, state)
|
||||
)
|
||||
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
|
||||
|
||||
await make_worker(
|
||||
tmp_path, FakeRepository(workflow=workflow), opencode=opencode
|
||||
)._abort_job_sessions(job(workflow_id="flow"))
|
||||
|
||||
assert set(opencode.aborted) == {(session, workspace) for session in expected}
|
||||
|
||||
|
||||
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
|
||||
async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None:
|
||||
opencode = FakeOpenCode()
|
||||
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
|
||||
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
|
||||
cast(JobState, state)
|
||||
|
||||
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(
|
||||
job(workflow_id="missing", session_id="session")
|
||||
)
|
||||
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
|
||||
|
||||
assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")]
|
||||
|
||||
|
||||
async def test_run_starts_configured_job_consumers(tmp_path: Path, monkeypatch) -> None:
|
||||
value = worker(tmp_path, FakeStorage(), FakeOpenCode())
|
||||
queues = []
|
||||
async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
workspace_path=tmp_path / "workflow" / "repo",
|
||||
base_sha="base",
|
||||
)
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
async def recover() -> None:
|
||||
pass
|
||||
await make_worker(
|
||||
tmp_path, FakeRepository(workflow=workflow), opencode=opencode
|
||||
)._abort_job_sessions(job(workflow_id="flow", session_id="one-shot"))
|
||||
|
||||
async def loop(queue, _stop) -> None:
|
||||
queues.append(queue)
|
||||
assert opencode.aborted == []
|
||||
|
||||
monkeypatch.setattr(value, "_recover", recover)
|
||||
monkeypatch.setattr(value, "_loop", loop)
|
||||
|
||||
await value.run(asyncio.Event())
|
||||
async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
assert queues.count("control") == 1
|
||||
assert queues.count("jobs") == 2
|
||||
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job())
|
||||
|
||||
assert opencode.aborted == []
|
||||
|
||||
|
||||
def test_safe_error_is_single_line_and_bounded() -> None:
|
||||
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
|
||||
|
||||
assert "\n" not in value
|
||||
assert len(value) == 1000
|
||||
|
||||
Reference in New Issue
Block a user