agent: Implemented the explicit persisted webhook state machine.

This commit is contained in:
2026-07-21 14:13:58 +00:00
parent 73045258fa
commit 378e372a4b
27 changed files with 1295 additions and 762 deletions
+2 -1
View File
@@ -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"
+34
View File
@@ -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()
+75
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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