agent: Implemented the explicit persisted webhook state machine.
This commit is contained in:
@@ -27,7 +27,8 @@ def test_empty_install_scripts_disable_setup() -> None:
|
||||
assert settings.install_scripts == []
|
||||
|
||||
|
||||
def test_defaults_research_variant_to_high() -> None:
|
||||
def test_defaults_research_variant_to_high(monkeypatch) -> None:
|
||||
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
|
||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert settings.research_variant == "high"
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
||||
|
||||
|
||||
def client(tmp_path: Path, status: int) -> OpenCodeClient:
|
||||
return OpenCodeClient(
|
||||
base_url="http://opencode:4096",
|
||||
username="opencode",
|
||||
password="secret",
|
||||
schemas_dir=tmp_path,
|
||||
health_directory=tmp_path,
|
||||
required_models=(),
|
||||
timeout_seconds=60,
|
||||
transport=httpx.MockTransport(lambda _request: httpx.Response(status)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [200, 204, 404, 409])
|
||||
async def test_absent_or_inactive_session_is_success(tmp_path: Path, status: int) -> None:
|
||||
value = client(tmp_path, status)
|
||||
await value.abort("session", tmp_path)
|
||||
await value.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 429, 500])
|
||||
async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -> None:
|
||||
value = client(tmp_path, status)
|
||||
with pytest.raises(OpenCodeError):
|
||||
await value.abort("session", tmp_path)
|
||||
await value.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.domain.events import (
|
||||
CommandReceived,
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobStarted,
|
||||
PermissionGranted,
|
||||
ServiceRestarted,
|
||||
)
|
||||
from agentci.domain.models import JobKind, JobStatus
|
||||
from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment
|
||||
|
||||
|
||||
def received(body: str = "/agent plan message"):
|
||||
return next_state(
|
||||
None,
|
||||
CommandReceived(
|
||||
job_id="job",
|
||||
delivery_id="delivery",
|
||||
receive_sequence=1,
|
||||
command_body=body,
|
||||
target_key="org/repo:issue:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
comment_id=4,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_permission_parses_and_queues_execution() -> None:
|
||||
transition = next_state(received().state, PermissionGranted(job_id="job"))
|
||||
assert transition.state.status is JobStatus.QUEUED
|
||||
assert transition.state.kind is JobKind.PLAN
|
||||
assert transition.state.message == "message"
|
||||
assert [(item.listener, item.queue) for item in transition.notifications] == [
|
||||
("execute", "jobs"),
|
||||
("reconcile_comment", "control"),
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_syntax_is_rejected_after_permission() -> None:
|
||||
transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job"))
|
||||
assert transition.state.status is JobStatus.REJECTED
|
||||
assert "Unknown" in (transition.state.error or "")
|
||||
|
||||
|
||||
def test_running_completion_and_restart_are_explicit() -> None:
|
||||
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
||||
running = next_state(queued, JobStarted(job_id="job")).state
|
||||
completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state
|
||||
assert completed.status is JobStatus.SUCCEEDED
|
||||
assert "# Result" in render_job_comment(completed)
|
||||
assert next_state(completed, ServiceRestarted(job_id="job")).state == completed
|
||||
|
||||
|
||||
def test_comment_link_is_allowed_on_terminal_state() -> None:
|
||||
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
||||
running = next_state(queued, JobStarted(job_id="job")).state
|
||||
completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state
|
||||
linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state
|
||||
assert linked.accepted_comment_id == 9
|
||||
|
||||
|
||||
def test_state_is_immutable_and_invalid_transitions_fail() -> None:
|
||||
state = received().state
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.stage = "changed" # type: ignore[misc]
|
||||
with pytest.raises(InvalidTransition):
|
||||
next_state(state, JobStarted(job_id="job"))
|
||||
+67
-95
@@ -4,14 +4,14 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from agentci.adapters.storage import Storage
|
||||
from agentci.domain.models import (
|
||||
Job,
|
||||
JobKind,
|
||||
JobStatus,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
WorkflowStatus,
|
||||
from agentci.domain.events import (
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
WorkflowCreated,
|
||||
)
|
||||
from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.state_machine import StateMachine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -22,40 +22,60 @@ async def storage(tmp_path: Path) -> Storage:
|
||||
return value
|
||||
|
||||
|
||||
def make_job(job_id: str = "job-1") -> Job:
|
||||
return Job(
|
||||
id=job_id,
|
||||
kind=JobKind.PLAN,
|
||||
target_key="alice/repo:issue:3",
|
||||
def command(delivery: str, body: str = "/agent plan") -> CommandEvent:
|
||||
return CommandEvent(
|
||||
delivery_id=delivery,
|
||||
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
pr_number=None,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=10,
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_enqueue_is_idempotent_and_claims_fifo(storage: Storage) -> None:
|
||||
assert await storage.enqueue("delivery-1", make_job())
|
||||
assert not await storage.enqueue("delivery-1", make_job("job-2"))
|
||||
claimed = await storage.claim_next()
|
||||
assert claimed is not None
|
||||
assert claimed.id == "job-1"
|
||||
assert claimed.status is JobStatus.RUNNING
|
||||
assert await storage.claim_next() is None
|
||||
async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
first = await host.receive(command("delivery-1"))
|
||||
duplicate = await host.receive(command("delivery-1"))
|
||||
second = await host.receive(command("delivery-2"))
|
||||
|
||||
assert not first.duplicate
|
||||
assert duplicate.duplicate
|
||||
assert duplicate.state.id == first.state.id
|
||||
assert second.state.receive_sequence == first.state.receive_sequence + 1
|
||||
|
||||
|
||||
async def test_recovers_running_job_as_failed(storage: Storage) -> None:
|
||||
await storage.enqueue("delivery-1", make_job())
|
||||
assert await storage.claim_next() is not None
|
||||
recovered = await storage.recover_running()
|
||||
assert [job.id for job in recovered] == ["job-1"]
|
||||
assert await storage.claim_next() is None
|
||||
async def test_received_job_blocks_later_execute_task(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
first = (await host.receive(command("delivery-1"))).state
|
||||
second = (await host.receive(command("delivery-2"))).state
|
||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
||||
|
||||
assert await storage.claim_task("jobs") is None
|
||||
|
||||
await host.evolve("deny-1", PermissionDenied(job_id=first.id))
|
||||
task = await storage.claim_task("jobs")
|
||||
assert task is not None
|
||||
assert task.job_id == second.id
|
||||
|
||||
|
||||
async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) -> None:
|
||||
async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None:
|
||||
host = StateMachine(storage)
|
||||
state = (await host.receive(command("delivery-1"))).state
|
||||
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
||||
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
||||
with sqlite3.connect(storage.database_path) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] is not None
|
||||
assert row[1] is None
|
||||
|
||||
|
||||
async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None:
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
kind=WorkflowKind.PLAN,
|
||||
@@ -68,84 +88,36 @@ async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) ->
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await storage.create_workflow(workflow)
|
||||
await storage.fail_job_workflow("missing-job")
|
||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
assert loaded is not None
|
||||
assert loaded.artifact == "# Plan"
|
||||
assert loaded.workspace_path == tmp_path / "repo"
|
||||
assert loaded.status is WorkflowStatus.COMPLETED
|
||||
|
||||
|
||||
async def test_tracks_operational_comments(storage: Storage) -> None:
|
||||
await storage.enqueue("delivery-1", make_job())
|
||||
await storage.set_job_comment("job-1", "accepted_comment_id", 21)
|
||||
await storage.set_job_comment("job-1", "started_comment_id", 22)
|
||||
assert await storage.operational_comment_ids("alice", "repo", 3) == {21, 22}
|
||||
|
||||
|
||||
async def test_failed_followup_does_not_invalidate_completed_workflow(
|
||||
storage: Storage, tmp_path: Path
|
||||
) -> None:
|
||||
async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None:
|
||||
host = StateMachine(storage)
|
||||
state = (await host.receive(command("delivery-1"))).state
|
||||
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
||||
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
id="workflow-atomic",
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner="alice",
|
||||
repo_name="repo",
|
||||
issue_number=3,
|
||||
workspace_path=tmp_path / "repo",
|
||||
base_sha="abc",
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await storage.create_workflow(workflow)
|
||||
job = make_job()
|
||||
job.workflow_id = workflow.id
|
||||
await storage.enqueue("delivery-1", job)
|
||||
await storage.fail_job_workflow(job.id)
|
||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
assert loaded is not None
|
||||
assert loaded.status is WorkflowStatus.COMPLETED
|
||||
|
||||
|
||||
async def test_opencode_migration_preserves_and_tags_legacy_session_ids(tmp_path: Path) -> None:
|
||||
legacy_migrations = tmp_path / "legacy-migrations"
|
||||
legacy_migrations.mkdir()
|
||||
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
(legacy_migrations / "001_initial.sql").write_text(
|
||||
(migrations / "001_initial.sql").read_text()
|
||||
result = await host.evolve(
|
||||
"workflow-created",
|
||||
WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
|
||||
)
|
||||
database = tmp_path / "legacy.sqlite3"
|
||||
legacy = Storage(database, legacy_migrations)
|
||||
await legacy.initialize()
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO workflows (
|
||||
id, kind, repo_owner, repo_name, issue_number, base_sha,
|
||||
workspace_path, primary_session_id, reviewer_session_id,
|
||||
artifact, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"legacy-workflow",
|
||||
"plan",
|
||||
"alice",
|
||||
"repo",
|
||||
3,
|
||||
"abc",
|
||||
str(tmp_path / "repo"),
|
||||
"legacy-primary",
|
||||
"legacy-reviewer",
|
||||
"# Preserved plan",
|
||||
"completed",
|
||||
"2026-07-20T00:00:00+00:00",
|
||||
"2026-07-20T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
assert result.state.workflow_id == workflow.id
|
||||
assert await storage.get_workflow(workflow.id) is not None
|
||||
|
||||
migrated = Storage(database, migrations)
|
||||
await migrated.initialize()
|
||||
loaded = await migrated.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.artifact == "# Preserved plan"
|
||||
assert loaded.primary_session_id == "legacy-primary"
|
||||
assert loaded.reviewer_session_id == "legacy-reviewer"
|
||||
assert loaded.runtime == "codex"
|
||||
async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None:
|
||||
with sqlite3.connect(storage.database_path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
|
||||
assert "receive_sequence" in columns
|
||||
assert "version" not in columns
|
||||
|
||||
+36
-95
@@ -1,54 +1,29 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self) -> None:
|
||||
self.jobs = []
|
||||
self.deliveries: set[str] = set()
|
||||
class FakeHost:
|
||||
def __init__(self, duplicate: bool = False) -> None:
|
||||
self.events = []
|
||||
self.duplicate = duplicate
|
||||
|
||||
async def enqueue(self, delivery_id, job):
|
||||
if delivery_id in self.deliveries:
|
||||
return False
|
||||
self.deliveries.add(delivery_id)
|
||||
self.jobs.append(job)
|
||||
return True
|
||||
|
||||
async def record_delivery(self, delivery_id, _comment_id):
|
||||
if delivery_id in self.deliveries:
|
||||
return False
|
||||
self.deliveries.add(delivery_id)
|
||||
return True
|
||||
|
||||
async def set_job_comment(self, *_args):
|
||||
return None
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, permitted: bool = True) -> None:
|
||||
self.permitted = permitted
|
||||
self.comments: list[str] = []
|
||||
|
||||
async def has_write_permission(self, *_args):
|
||||
return self.permitted
|
||||
|
||||
async def create_comment(self, _owner, _repo, _number, body):
|
||||
self.comments.append(body)
|
||||
return len(self.comments)
|
||||
async def receive(self, event):
|
||||
self.events.append(event)
|
||||
state = SimpleNamespace(id="job", receive_sequence=1)
|
||||
return SimpleNamespace(state=state, duplicate=self.duplicate)
|
||||
|
||||
|
||||
def payload(body: str, *, is_pull: bool = False) -> dict:
|
||||
value = {
|
||||
"action": "created",
|
||||
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
|
||||
"repository": {
|
||||
"name": "repo",
|
||||
"owner": {"login": "org"},
|
||||
},
|
||||
"repository": {"name": "repo", "owner": {"login": "org"}},
|
||||
"issue": {"number": 4},
|
||||
"is_pull": is_pull,
|
||||
}
|
||||
@@ -57,74 +32,40 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
def test_extracts_pull_request_event() -> None:
|
||||
event = _event_from_payload("delivery", payload("/agent fix now", is_pull=True))
|
||||
assert event is not None
|
||||
assert event.pr_number == 4
|
||||
assert event.target_key == "org/repo:pr:4"
|
||||
|
||||
|
||||
async def test_authorized_command_is_queued() -> None:
|
||||
storage = FakeStorage()
|
||||
gitea = FakeGitea()
|
||||
container = SimpleNamespace(storage=storage, gitea=gitea)
|
||||
event = _event_from_payload("delivery", payload("/agent plan consider migrations"))
|
||||
assert event is not None
|
||||
response = await _handle_command(container, event)
|
||||
assert response.status_code == 202
|
||||
assert len(storage.jobs) == 1
|
||||
assert "queued" in gitea.comments[0]
|
||||
|
||||
|
||||
async def test_unauthorized_command_is_rejected_and_deduplicated() -> None:
|
||||
storage = FakeStorage()
|
||||
gitea = FakeGitea(permitted=False)
|
||||
container = SimpleNamespace(storage=storage, gitea=gitea)
|
||||
event = _event_from_payload("delivery", payload("/agent implement"))
|
||||
assert event is not None
|
||||
|
||||
await _handle_command(container, event)
|
||||
await _handle_command(container, event)
|
||||
|
||||
assert storage.jobs == []
|
||||
assert len(gitea.comments) == 1
|
||||
assert "write permission" in gitea.comments[0]
|
||||
|
||||
|
||||
async def test_iterate_message_is_preserved_on_queued_job() -> None:
|
||||
storage = FakeStorage()
|
||||
container = SimpleNamespace(storage=storage, gitea=FakeGitea())
|
||||
async def test_command_is_forwarded_without_parsing() -> None:
|
||||
host = FakeHost()
|
||||
event = _event_from_payload(
|
||||
"delivery",
|
||||
payload(
|
||||
"/agent iterate\n\nkeep the API stable\nlimit changes to the parser",
|
||||
is_pull=True,
|
||||
),
|
||||
"delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
|
||||
)
|
||||
assert event is not None
|
||||
|
||||
response = await _handle_command(container, event)
|
||||
|
||||
response = await _handle_command(SimpleNamespace(state_machine=host), event)
|
||||
assert response.status_code == 202
|
||||
assert len(storage.jobs) == 1
|
||||
assert storage.jobs[0].message == (
|
||||
"keep the API stable\nlimit changes to the parser"
|
||||
)
|
||||
assert host.events[0].body == "/agent iterate\n\nkeep raw body"
|
||||
|
||||
|
||||
async def test_duplicate_returns_200() -> None:
|
||||
event = _event_from_payload("delivery", payload("/agent plan"))
|
||||
assert event is not None
|
||||
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_missing_delivery_is_rejected() -> None:
|
||||
event = _event_from_payload("", payload("/agent plan"))
|
||||
assert event is not None
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
||||
assert raised.value.status_code == 400
|
||||
|
||||
|
||||
async def test_non_command_is_ignored() -> None:
|
||||
container = SimpleNamespace(storage=FakeStorage(), gitea=FakeGitea())
|
||||
event = _event_from_payload("delivery", payload("ordinary discussion"))
|
||||
assert event is not None
|
||||
response = await _handle_command(container, event)
|
||||
response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_rejects_bad_signature() -> None:
|
||||
def test_signature_validation() -> None:
|
||||
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
|
||||
assert valid_signature(b"secret", b"{}", signature)
|
||||
assert not valid_signature(b"secret", b"{}", "bad")
|
||||
|
||||
|
||||
def test_accepts_valid_signature() -> None:
|
||||
body = json.dumps(payload("ordinary comment")).encode()
|
||||
signature = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
|
||||
assert valid_signature(b"secret", body, signature)
|
||||
|
||||
+25
-119
@@ -1,96 +1,42 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from agentci.domain.models import Job, JobKind, JobStatus, Workflow, WorkflowKind
|
||||
from agentci.worker import Worker
|
||||
from agentci.workflows.common import JobRejected
|
||||
from agentci.domain.models import Workflow, WorkflowKind
|
||||
from agentci.domain.state_machine import JobState
|
||||
from agentci.worker import Worker, _safe_error
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, workflow: Workflow | None) -> None:
|
||||
def __init__(self, workflow=None) -> None:
|
||||
self.workflow = workflow
|
||||
|
||||
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
|
||||
async def get_workflow(self, _workflow_id):
|
||||
return self.workflow
|
||||
|
||||
|
||||
class FakeOpenCode:
|
||||
def __init__(self) -> None:
|
||||
self.aborted: set[tuple[str, Path]] = set()
|
||||
self.aborted = set()
|
||||
|
||||
async def abort(self, session_id: str, workspace: Path) -> None:
|
||||
async def abort(self, session_id, workspace):
|
||||
self.aborted.add((session_id, workspace))
|
||||
|
||||
|
||||
class JobStorage(FakeStorage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(None)
|
||||
self.updates: list[tuple[JobStatus | None, str | None, str | None]] = []
|
||||
self.comment_ids: list[int] = []
|
||||
|
||||
async def update_job(self, _job_id: str, **values) -> None:
|
||||
self.updates.append(
|
||||
(values.get("status"), values.get("stage"), values.get("error"))
|
||||
)
|
||||
|
||||
async def set_job_comment(self, _job_id: str, _column: str, comment_id: int) -> None:
|
||||
self.comment_ids.append(comment_id)
|
||||
|
||||
async def job_stage(self, _job_id: str) -> str:
|
||||
return "working"
|
||||
|
||||
async def fail_job_workflow(self, _job_id: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[str] = []
|
||||
self.updated: list[tuple[int, str]] = []
|
||||
|
||||
async def create_comment(self, _owner: str, _repo: str, _number: int, body: str) -> int:
|
||||
self.created.append(body)
|
||||
return 42
|
||||
|
||||
async def update_comment(
|
||||
self, _owner: str, _repo: str, comment_id: int, body: str
|
||||
) -> None:
|
||||
self.updated.append((comment_id, body))
|
||||
|
||||
|
||||
class RejectingDispatcher:
|
||||
async def dispatch(self, _job: Job) -> None:
|
||||
raise JobRejected("not applicable")
|
||||
|
||||
|
||||
def job(*, workflow_id: str | None, runtime_session_id: str | None = None) -> Job:
|
||||
return Job(
|
||||
id="job",
|
||||
kind=JobKind.FIX,
|
||||
target_key="org/repo:pr:1",
|
||||
repo_owner="org",
|
||||
repo_name="repo",
|
||||
issue_number=1,
|
||||
pr_number=1,
|
||||
requester="alice",
|
||||
message="",
|
||||
comment_id=1,
|
||||
workflow_id=workflow_id,
|
||||
runtime_session_id=runtime_session_id,
|
||||
)
|
||||
|
||||
|
||||
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
|
||||
return Worker(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
gitea=None, # type: ignore[arg-type]
|
||||
state_machine=SimpleNamespace(), # type: ignore[arg-type]
|
||||
gitea=SimpleNamespace(), # type: ignore[arg-type]
|
||||
opencode=opencode, # type: ignore[arg-type]
|
||||
dispatcher=None, # type: ignore[arg-type]
|
||||
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
|
||||
poll_seconds=1,
|
||||
workspaces_dir=tmp_path,
|
||||
bot_username="agentci",
|
||||
)
|
||||
|
||||
|
||||
async def test_recovery_aborts_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workflow" / "repo"
|
||||
workflow = Workflow(
|
||||
id="flow",
|
||||
@@ -104,63 +50,23 @@ async def test_recovery_aborts_all_workflow_sessions(tmp_path: Path) -> None:
|
||||
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(
|
||||
job(workflow_id=workflow.id)
|
||||
cast(JobState, state)
|
||||
)
|
||||
|
||||
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
|
||||
|
||||
|
||||
async def test_recovery_aborts_one_shot_fix_session(tmp_path: Path) -> None:
|
||||
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
|
||||
opencode = FakeOpenCode()
|
||||
|
||||
await worker(tmp_path, FakeStorage(None), opencode)._abort_job_sessions(
|
||||
job(workflow_id=None, runtime_session_id="fix-session")
|
||||
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
|
||||
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
|
||||
cast(JobState, state)
|
||||
)
|
||||
|
||||
assert opencode.aborted == {("fix-session", tmp_path / "fix-job" / "repo")}
|
||||
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
|
||||
|
||||
|
||||
async def test_job_status_updates_existing_gitea_comment(tmp_path: Path) -> None:
|
||||
storage = JobStorage()
|
||||
gitea = FakeGitea()
|
||||
active_job = job(workflow_id=None)
|
||||
active_job.accepted_comment_id = 41
|
||||
value = Worker(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
gitea=gitea, # type: ignore[arg-type]
|
||||
opencode=FakeOpenCode(), # type: ignore[arg-type]
|
||||
dispatcher=RejectingDispatcher(), # type: ignore[arg-type]
|
||||
poll_seconds=1,
|
||||
workspaces_dir=tmp_path,
|
||||
)
|
||||
|
||||
await value._run_job(active_job)
|
||||
|
||||
assert gitea.created == []
|
||||
assert [comment_id for comment_id, _body in gitea.updated] == [41, 41]
|
||||
assert "started" in gitea.updated[0][1]
|
||||
assert "rejected" in gitea.updated[1][1]
|
||||
assert storage.updates[-1][:2] == (JobStatus.REJECTED, "rejected")
|
||||
|
||||
|
||||
async def test_worker_creates_only_one_comment_when_queue_comment_is_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
storage = JobStorage()
|
||||
gitea = FakeGitea()
|
||||
value = Worker(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
gitea=gitea, # type: ignore[arg-type]
|
||||
opencode=FakeOpenCode(), # type: ignore[arg-type]
|
||||
dispatcher=RejectingDispatcher(), # type: ignore[arg-type]
|
||||
poll_seconds=1,
|
||||
workspaces_dir=tmp_path,
|
||||
)
|
||||
|
||||
await value._run_job(job(workflow_id=None))
|
||||
|
||||
assert len(gitea.created) == 1
|
||||
assert storage.comment_ids == [42]
|
||||
assert [comment_id for comment_id, _body in gitea.updated] == [42]
|
||||
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