Files
agentci/tests/test_repository_tasks.py
2026-07-22 23:10:23 +02:00

194 lines
6.3 KiB
Python

import sqlite3
from contextlib import closing
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from agentci.engine import _sqlite
from agentci.engine import repository as repository_module
from agentci.engine.events import JobStarted, PermissionGranted
from agentci.engine.model import IncomingCommand, QueueName, Task, TaskKind
from agentci.engine.repository import Repository
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
@pytest.fixture
async def repository(tmp_path: Path) -> Repository:
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
await value.initialize()
return value
def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
return IncomingCommand(
delivery_id=delivery,
comment_id=int(delivery.rsplit("-", 1)[-1]),
repo_owner="alice",
repo_name="repo",
issue_number=issue,
pr_number=None,
requester="alice",
body="/agent plan",
)
def task_storage(repository: Repository, task_id: int) -> tuple[object, ...]:
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
row = connection.execute(
"""SELECT status, attempts, available_at, error, created_at, started_at, finished_at
FROM listener_tasks WHERE id=?""",
(task_id,),
).fetchone()
assert row is not None
return row
async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps(
repository: Repository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = {"now": "2026-02-01T00:00:00+00:00"}
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
job = (await repository.accept(command("delivery-1"))).job
clock["now"] = "2026-02-01T00:01:00+00:00"
task = await repository.claim_task(QueueName.CONTROL)
assert task is not None
clock["now"] = "2026-02-01T00:02:00+00:00"
await repository.complete_task(task.id)
assert (task, task_storage(repository, task.id)) == (
Task(
id=task.id,
job_id=job.id,
source_event_id="delivery:delivery-1",
kind=TaskKind.AUTHORIZE,
queue=QueueName.CONTROL,
attempts=1,
),
(
"completed",
1,
"2026-02-01T00:00:00+00:00",
None,
"2026-02-01T00:00:00+00:00",
"2026-02-01T00:01:00+00:00",
"2026-02-01T00:02:00+00:00",
),
)
@pytest.mark.parametrize(("attempts", "delay_seconds"), [(1, 2), (9, 256)])
async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_due(
repository: Repository,
monkeypatch: pytest.MonkeyPatch,
attempts: int,
delay_seconds: int,
) -> None:
clock = {"now": "2026-02-01T00:00:00+00:00"}
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
await repository.accept(command("delivery-1"))
task = await repository.claim_task(QueueName.CONTROL)
assert task is not None
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
connection.execute("UPDATE listener_tasks SET attempts=? WHERE id=?", (attempts, task.id))
retry_time = datetime(2026, 2, 1, 1, tzinfo=UTC)
available_at = retry_time + timedelta(seconds=delay_seconds)
class FixedDateTime:
@staticmethod
def now(_timezone: object) -> datetime:
return retry_time
monkeypatch.setattr(repository_module, "datetime", FixedDateTime)
error = "failure: " + "x" * 1100
await repository.retry_task(task.id, attempts, error)
pending_storage = task_storage(repository, task.id)
clock["now"] = (available_at - timedelta(microseconds=1)).isoformat()
early_claim = await repository.claim_task(QueueName.CONTROL)
clock["now"] = available_at.isoformat()
due_claim = await repository.claim_task(QueueName.CONTROL)
assert (
pending_storage,
early_claim,
due_claim and due_claim.attempts,
) == (
(
"pending",
attempts,
available_at.isoformat(),
error[:1000],
"2026-02-01T00:00:00+00:00",
"2026-02-01T00:00:00+00:00",
None,
),
None,
attempts + 1,
)
async def test_recovery_requeues_control_and_unstarted_execution_but_fails_started_execution(
repository: Repository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = {"now": "2026-02-01T00:00:00+00:00"}
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
await repository.accept(command("delivery-1", issue=1))
control_task = await repository.claim_task(QueueName.CONTROL)
assert control_task is not None
queued_job = (await repository.accept(command("delivery-2", issue=2))).job
await repository.apply("queued:grant", PermissionGranted(job_id=queued_job.id))
queued_execute = await repository.claim_task(QueueName.JOBS)
assert queued_execute is not None
running_job = (await repository.accept(command("delivery-3", issue=3))).job
await repository.apply("running:grant", PermissionGranted(job_id=running_job.id))
running_execute = await repository.claim_task(QueueName.JOBS)
assert running_execute is not None
await repository.apply("running:start", JobStarted(job_id=running_job.id))
clock["now"] = "2026-02-01T01:00:00+00:00"
await repository.recover_tasks()
assert (
task_storage(repository, control_task.id),
task_storage(repository, queued_execute.id),
task_storage(repository, running_execute.id),
[job.id for job in await repository.running_jobs()],
) == (
(
"pending",
1,
"2026-02-01T00:00:00+00:00",
None,
"2026-02-01T00:00:00+00:00",
None,
None,
),
(
"pending",
1,
"2026-02-01T00:00:00+00:00",
None,
"2026-02-01T00:00:00+00:00",
None,
None,
),
(
"failed",
1,
"2026-02-01T00:00:00+00:00",
"Service restarted after execution began",
"2026-02-01T00:00:00+00:00",
"2026-02-01T00:00:00+00:00",
"2026-02-01T01:00:00+00:00",
),
[running_job.id],
)