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
+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