agent: Implemented the explicit persisted webhook state machine.
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user