rewrite phase 1

This commit is contained in:
2026-07-22 23:10:23 +02:00
parent 7527831af6
commit 98ac4abca1
89 changed files with 9179 additions and 2795 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+132
View File
@@ -0,0 +1,132 @@
# Agent CI repository instructions
## Scope
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
recovery, deployment, or the security boundary.
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
the requested change merely for consistency.
## Repository map
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
task claiming, and the `JobRun` event interface.
- `src/agentci/worker.py`: durable control/job queues, authorization, execution, recovery, and
comment reconciliation.
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
- `src/agentci/{gitea,git,opencode,codegraph,development}.py`: external-effect boundaries.
- `src/agentci/{app,runtime,config,webhook,health}.py`: application lifecycle, configuration, and
HTTP entry points.
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
contracts; keep these concerns outside Python orchestration.
- `src/agentci/migrations/`: ordered SQLite migrations.
- `tests/`: pytest suite, generally organized by module or behavior.
- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and
trusted runtime configuration.
## Setup and commands
Use Python 3.13 or newer and `uv`. Run commands from the repository root.
```sh
uv sync
```
Run the narrowest relevant test while iterating:
```sh
uv run pytest tests/test_<area>.py
uv run pytest tests/test_<area>.py::test_<behavior>
uv run pytest -k '<expression>'
```
Run the standard Python checks before completion:
```sh
uv run ruff check .
uv run pyright
uv run pytest
```
Coverage is diagnostic and has no required threshold:
```sh
uv run pytest --cov=agentci --cov-branch
```
For deployment-related changes, also run:
```sh
docker compose config
```
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
OpenCode configuration. It may require network access and takes longer than the normal checks.
## Engineering conventions
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
rather than mutating state in place.
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
events and task requests.
- Apply state transitions, event persistence, and resulting task creation atomically through
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
deterministic comment reconciliation.
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
failed and its sessions are aborted rather than replayed.
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
log credentials, secret contents, authorization headers, or private prompt data.
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
applied.
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
hand.
## Testing conventions
- Add regression tests for behavior changes, especially reducer transitions, persistence and
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
asynchronous.
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
error text rather than private implementation details.
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
or network access.
## Security and operational boundaries
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime
databases, cloned private repositories, or Docker volume contents. Do not send secrets or private
repository content to external search or research services.
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
secret-file loading.
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
or external skills, expose its server to the host, add privileged/capability settings, or add
writable host mounts without an explicit security review.
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
credentials to them.
- Git pushes performed by Agent CI must remain non-forcing.
- Do not run provider authentication, start/restart deployment services, modify production data, or
perform other live operations unless the user explicitly requests it.
- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`,
`.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`.
## Completion expectations
- Run focused tests first, then all applicable standard checks. If a check cannot run, report the
exact command and reason.
- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands,
configuration, deployment, recovery behavior, or security assumptions.
- Summarize behavior changes, validation performed, and any migration, compatibility, or security
implications in the final response or pull-request description.
+22 -2
View File
@@ -4,6 +4,23 @@ Agent CI is a private Gitea webhook host that turns issue and pull-request comme
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
private OpenCode server on the same Docker network as Gitea. private OpenCode server on the same Docker network as Gitea.
## Architecture
The service has one durable execution path:
```text
webhook -> repository -> reducer -> durable task -> worker -> workflow -> integration
```
`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions
atomically to SQLite and persists the resulting tasks. `worker.py` executes those tasks and passes
an explicit `JobRun` into the functions under `workflows/`. The top-level Gitea, Git, OpenCode,
development, and CodeGraph modules own external effects.
Jobs and workflows are immutable snapshots. Workflows return their final comment body directly;
progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table
is the authoritative state snapshot, while `job_events` provides durable idempotency and audit data.
## Commands ## Commands
| Location | Command | Behavior | | Location | Command | Behavior |
@@ -153,9 +170,12 @@ uv sync
uv run ruff check . uv run ruff check .
uv run pyright uv run pyright
uv run pytest uv run pytest
uv run pytest --cov=agentci --cov-branch
docker compose config docker compose config
docker compose build docker compose build
``` ```
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside The coverage command is an opt-in diagnostic report; the regular test run remains the default and
Python so orchestration modules remain small and readable. coverage percentage is not used as a pass threshold.
Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow.
+1 -1
View File
@@ -20,7 +20,7 @@ dev = [
"pyright>=1.1.403", "pyright>=1.1.403",
"pytest>=8.4,<9", "pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2", "pytest-asyncio>=1.1,<2",
"respx>=0.22,<1", "pytest-cov>=6,<8",
"ruff>=0.12,<1", "ruff>=0.12,<1",
] ]
-2
View File
@@ -1,3 +1 @@
"""Gitea-triggered OpenCode workflow host.""" """Gitea-triggered OpenCode workflow host."""
__version__ = "0.1.0"
-2
View File
@@ -1,2 +0,0 @@
"""External-system adapters."""
-75
View File
@@ -1,75 +0,0 @@
from __future__ import annotations
import logging
import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TypeVar
T = TypeVar("T")
log = logging.getLogger(__name__)
def now() -> str:
return datetime.now(UTC).isoformat()
class Database:
def __init__(self, database_path: Path, migrations_dir: Path) -> None:
self.database_path = database_path
self.migrations_dir = migrations_dir
async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True)
try:
await self._run(self._initialize_sync)
except Exception:
log.exception(
"database initialization failed", extra={"operation": "database.initialize"}
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _initialize_sync(self, connection: sqlite3.Connection) -> None:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row[0] for row in connection.execute("SELECT version FROM schema_migrations")
}
for path in sorted(self.migrations_dir.glob("*.sql")):
version = int(path.name.split("_", 1)[0])
if version in applied:
continue
connection.executescript(path.read_text())
connection.execute(
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
(version, now()),
)
async def _update(self, table: str, row_id: str, updates: dict[str, object]) -> None:
if not updates:
return
columns = ", ".join(f"{column}=?" for column in updates)
values = [*updates.values(), row_id]
await self._run(
lambda connection: connection.execute(
f"UPDATE {table} SET {columns} WHERE id=?", values
)
)
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
# Operations are deliberately tiny and serialized by the single worker.
# Avoid a thread pool so SQLite transactions retain deterministic ordering.
with self._connect() as connection:
return operation(connection)
-46
View File
@@ -1,46 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RepositoryInfo:
owner: str
name: str
full_name: str
default_branch: str
@dataclass(frozen=True)
class IssueInfo:
number: int
title: str
body: str
state: str
@dataclass(frozen=True)
class CommentInfo:
id: int
author: str
body: str
created_at: str
@dataclass(frozen=True)
class PullRequestInfo:
number: int
title: str
body: str
state: str
merged: bool
base_branch: str
head_branch: str
head_sha: str
head_owner: str
head_repo: str
@property
def is_open(self) -> bool:
return self.state == "open" and not self.merged
-212
View File
@@ -1,212 +0,0 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from agentci.adapters.database import Database, now
from agentci.adapters.state_persistence import (
insert_event as _insert_event,
)
from agentci.adapters.state_persistence import (
insert_state as _insert_state,
)
from agentci.adapters.state_persistence import (
insert_tasks as _insert_tasks,
)
from agentci.adapters.state_persistence import (
insert_workflow as _insert_workflow,
)
from agentci.adapters.state_persistence import (
optional_state as _optional_state,
)
from agentci.adapters.state_persistence import (
replace_state as _replace_state,
)
from agentci.adapters.state_persistence import (
state as _state,
)
from agentci.adapters.state_persistence import (
state_from_row as _state_from_row,
)
from agentci.domain.events import CommandReceived, JobEvent, WorkflowCreated
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState, next_state
@dataclass(frozen=True)
class EvolveResult:
state: JobState
duplicate: bool
@dataclass(frozen=True)
class ListenerTask:
id: int
job_id: str
source_event_id: str
listener: str
queue: str
attempts: int
class JobStore(Database):
async def receive(
self, event_id: str, job_id: str, incoming: CommandEvent
) -> EvolveResult:
def operation(connection: sqlite3.Connection) -> EvolveResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
state = _state(connection, duplicate["job_id"])
connection.commit()
return EvolveResult(state, True)
sequence = connection.execute(
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
).fetchone()[0]
event = CommandReceived(
job_id=job_id,
delivery_id=incoming.delivery_id,
receive_sequence=sequence,
command_body=incoming.body,
target_key=incoming.target_key,
repo_owner=incoming.repo_owner,
repo_name=incoming.repo_name,
issue_number=incoming.issue_number,
pr_number=incoming.pr_number,
requester=incoming.requester,
comment_id=incoming.comment_id,
)
transition = next_state(None, event)
timestamp = now()
_insert_event(connection, event_id, event, timestamp)
_insert_state(connection, transition.state, timestamp)
_insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return EvolveResult(transition.state, False)
return await self._run(operation)
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
def operation(connection: sqlite3.Connection) -> EvolveResult:
connection.execute("BEGIN IMMEDIATE")
if connection.execute(
"SELECT 1 FROM job_events WHERE event_id=?", (event_id,)
).fetchone():
state = _state(connection, event.job_id)
connection.commit()
return EvolveResult(state, True)
current = _state(connection, event.job_id)
transition = next_state(current, event)
timestamp = now()
_insert_event(connection, event_id, event, timestamp)
if isinstance(event, WorkflowCreated):
_insert_workflow(connection, event, timestamp)
_replace_state(connection, transition.state, current, timestamp)
_insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return EvolveResult(transition.state, False)
return await self._run(operation)
async def get_job_state(self, job_id: str) -> JobState | None:
return await self._run(lambda connection: _optional_state(connection, job_id))
async def claim_task(self, queue: str) -> ListenerTask | None:
return await self._run(lambda connection: _claim_task(connection, queue))
async def complete_task(self, task_id: int) -> None:
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
(now(), task_id),
)
)
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
delay = min(2 ** min(attempts, 8), 300)
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
(available, error[:1000], task_id),
)
)
async def running_job_states(self) -> list[JobState]:
return await self._run(
lambda connection: [
_state_from_row(row)
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
]
)
async def recover_tasks(self) -> None:
def recover(connection: sqlite3.Connection) -> None:
with connection:
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND queue='control'"""
)
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status='queued')"""
)
connection.execute(
"""UPDATE listener_tasks SET status='failed', finished_at=?,
error='Service restarted after execution began'
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status<>'queued')""",
(now(),),
)
await self._run(recover)
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
return await self._run(
lambda connection: {
value
for row in connection.execute(
"SELECT accepted_comment_id, started_comment_id FROM jobs "
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
(owner, repo, issue),
)
for value in row
if value is not None
}
)
def _claim_task(connection: sqlite3.Connection, queue: str) -> ListenerTask | None:
connection.execute("BEGIN IMMEDIATE")
fifo = ""
if queue == "jobs":
fifo = """AND NOT EXISTS (
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
AND earlier.target_key = j.target_key
AND earlier.status IN ('received', 'queued', 'running'))"""
row = connection.execute(
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""",
(queue, now()),
).fetchone()
if row is None:
connection.commit()
return None
changed = connection.execute(
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
"WHERE id=? AND status='pending'",
(now(), row["id"]),
)
if changed.rowcount != 1:
connection.rollback()
return None
connection.commit()
return ListenerTask(
row["id"], row["job_id"], row["source_event_id"], row["listener"],
row["queue"], row["attempts"] + 1,
)
-77
View File
@@ -1,77 +0,0 @@
import json
from pathlib import Path
from time import monotonic
from typing import Any
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
try:
value = json.loads((schemas_dir / name).read_text())
except (OSError, ValueError) as exc:
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"Result schema {name} is not a JSON object")
return value
def api_contract_ready(document: object) -> bool:
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
return False
paths = document["paths"]
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
if any(method not in paths.get(path, {}) for path, method in fixed.items()):
return False
session_paths = [path for path in paths if path.startswith("/session/{")]
has_message = any(
path.endswith("/message") and "post" in paths[path] for path in session_paths
)
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
return has_message and has_abort
def models_ready(
payload: object, requirements: set[tuple[str, str, str | None]]
) -> bool:
if not isinstance(payload, dict):
return False
connected = set(payload.get("connected", []))
providers = {
item.get("id"): item
for item in payload.get("all", [])
if isinstance(item, dict) and isinstance(item.get("models"), dict)
}
for provider_id, model_id, variant in requirements:
provider = providers.get(provider_id)
if provider_id not in connected or not isinstance(provider, dict):
return False
model: Any = provider["models"].get(model_id)
if not isinstance(model, dict) or model.get("status") == "deprecated":
return False
if model.get("capabilities", {}).get("toolcall") is not True:
return False
if variant and variant not in model.get("variants", {}):
return False
return True
-115
View File
@@ -1,115 +0,0 @@
from __future__ import annotations
import sqlite3
from agentci.domain.events import JobEvent, WorkflowCreated
from agentci.domain.models import JobKind, JobStatus
from agentci.domain.state_machine import JobState, Transition
def insert_event(connection: sqlite3.Connection, event_id: str, event: JobEvent, ts: str) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), ts),
)
def insert_tasks(
connection: sqlite3.Connection, event_id: str, transition: Transition, timestamp: str
) -> None:
for ordinal, notification in enumerate(transition.notifications):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.state.id,
event_id,
ordinal,
notification.listener,
notification.queue,
timestamp,
timestamp,
),
)
def _values(state: JobState) -> tuple[object, ...]:
return (
state.id, state.kind, state.target_key, state.repo_owner, state.repo_name,
state.issue_number, state.pr_number, state.requester, state.message, state.comment_id,
state.delivery_id, state.receive_sequence, state.command_body, state.workflow_id,
state.status, state.stage, state.error, state.runtime_session_id,
state.accepted_comment_id, state.comment_body,
)
def insert_state(connection: sqlite3.Connection, state: JobState, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_values(state), timestamp),
)
def replace_state(
connection: sqlite3.Connection, state: JobState, previous: JobState, timestamp: str
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and state.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and state.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_values(state)[1:], started, finished, state.id),
)
def optional_state(connection: sqlite3.Connection, job_id: str) -> JobState | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return state_from_row(row) if row else None
def state(connection: sqlite3.Connection, job_id: str) -> JobState:
value = optional_state(connection, job_id)
if value is None:
raise KeyError(f"Unknown job {job_id}")
return value
def state_from_row(row: sqlite3.Row) -> JobState:
return JobState(
id=row["id"], kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"], repo_owner=row["repo_owner"], repo_name=row["repo_name"],
issue_number=row["issue_number"], pr_number=row["pr_number"], requester=row["requester"],
message=row["message"], comment_id=row["comment_id"], delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"], command_body=row["command_body"],
workflow_id=row["workflow_id"], status=JobStatus(row["status"]), stage=row["stage"],
error=row["error"], runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"], comment_body=row["comment_body"],
)
def insert_workflow(connection: sqlite3.Connection, event: WorkflowCreated, ts: str) -> None:
workflow = event.workflow
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id, workflow.kind, workflow.repo_owner, workflow.repo_name,
workflow.issue_number, workflow.pr_number, workflow.base_sha, workflow.branch,
str(workflow.workspace_path), workflow.primary_session_id,
workflow.reviewer_session_id, workflow.artifact, workflow.review_json,
workflow.status, workflow.runtime, ts, ts,
),
)
-7
View File
@@ -1,7 +0,0 @@
from agentci.adapters.job_store import JobStore
from agentci.adapters.workflow_store import WorkflowStore
class Storage(JobStore, WorkflowStore):
"""Combined durable job and workflow repository."""
-156
View File
@@ -1,156 +0,0 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from agentci.adapters.database import Database, now
from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus
class WorkflowStore(Database):
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"SELECT * FROM workflows WHERE id=?", (workflow_id,)
).fetchone()
)
)
async def create_workflow(self, workflow: Workflow) -> None:
timestamp = now()
await self._run(
lambda connection: connection.execute(
"""
INSERT INTO workflows (
id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id,
reviewer_session_id, artifact, review_json, status, runtime,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
workflow.id,
workflow.kind,
workflow.repo_owner,
workflow.repo_name,
workflow.issue_number,
workflow.pr_number,
workflow.base_sha,
workflow.branch,
str(workflow.workspace_path),
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status,
workflow.runtime,
timestamp,
timestamp,
),
)
)
async def update_workflow(self, workflow: Workflow) -> None:
await self._update(
"workflows",
workflow.id,
{
"pr_number": workflow.pr_number,
"branch": workflow.branch,
"primary_session_id": workflow.primary_session_id,
"reviewer_session_id": workflow.reviewer_session_id,
"artifact": workflow.artifact,
"review_json": workflow.review_json,
"status": workflow.status,
"updated_at": now(),
},
)
async def latest_workflow(
self,
owner: str,
repo: str,
issue: int,
kind: WorkflowKind,
) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"""
SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=?
AND kind=? AND status=?
ORDER BY created_at DESC LIMIT 1
""",
(owner, repo, issue, kind, WorkflowStatus.COMPLETED),
).fetchone()
)
)
async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"""
SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=?
ORDER BY created_at DESC LIMIT 1
""",
(owner, repo, pr, WorkflowKind.IMPLEMENT),
).fetchone()
)
)
async def implementation_workflows(
self, owner: str, repo: str, issue: int
) -> list[Workflow]:
return await self._run(
lambda connection: [
item
for row in connection.execute(
"""
SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=?
AND pr_number IS NOT NULL
ORDER BY created_at DESC
""",
(owner, repo, issue, WorkflowKind.IMPLEMENT),
)
if (item := workflow_from_row(row)) is not None
]
)
async def fail_job_workflow(self, job_id: str) -> None:
await self._run(
lambda connection: connection.execute(
"""
UPDATE workflows SET status=?, updated_at=?
WHERE id=(SELECT workflow_id FROM jobs WHERE id=?)
AND status=?
""",
(WorkflowStatus.FAILED, now(), job_id, WorkflowStatus.ACTIVE),
)
)
def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
if row is None:
return None
return Workflow(
id=row["id"],
kind=WorkflowKind(row["kind"]),
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
reviewer_session_id=row["reviewer_session_id"],
artifact=row["artifact"],
review_json=row["review_json"],
status=WorkflowStatus(row["status"]),
)
-2
View File
@@ -1,2 +0,0 @@
"""HTTP API."""
+7 -7
View File
@@ -8,11 +8,11 @@ from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from agentci.api.health import router as health_router
from agentci.api.webhook import router as webhook_router
from agentci.config import Settings from agentci.config import Settings
from agentci.container import build_container from agentci.health import router as health_router
from agentci.logging import configure_logging from agentci.logging import configure_logging
from agentci.runtime import build_runtime
from agentci.webhook import router as webhook_router
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -24,13 +24,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
configure_logging() configure_logging()
log.info("service startup started", extra={"operation": "service.startup"}) log.info("service startup started", extra={"operation": "service.startup"})
try: try:
container = await build_container(selected_settings) runtime = await build_runtime(selected_settings)
except Exception: except Exception:
log.exception("service startup failed", extra={"operation": "service.startup"}) log.exception("service startup failed", extra={"operation": "service.startup"})
raise raise
app.state.container = container app.state.runtime = runtime
stop = asyncio.Event() stop = asyncio.Event()
worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker") worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
try: try:
log.info("service startup completed", extra={"operation": "service.startup"}) log.info("service startup completed", extra={"operation": "service.startup"})
yield yield
@@ -42,7 +42,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await worker_task await worker_task
finally: finally:
await container.close() await runtime.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"}) log.info("service shutdown completed", extra={"operation": "service.shutdown"})
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan) app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
pass pass
class CodeGraphClient: class CodeGraph:
async def prepare(self, workspace: Path) -> None: async def prepare(self, workspace: Path) -> None:
self._exclude_index(workspace) self._exclude_index(workspace)
index = workspace / ".codegraph" / "codegraph.db" index = workspace / ".codegraph" / "codegraph.db"
-2
View File
@@ -1,2 +0,0 @@
"""Domain types and policies."""
-189
View File
@@ -1,189 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import JobKind, JobStatus
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Notification:
listener: str
queue: str = "control"
@dataclass(frozen=True)
class JobState:
id: str
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None
runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass(frozen=True)
class Transition:
state: JobState
notifications: tuple[Notification, ...] = ()
RECONCILE = Notification("reconcile_comment")
def next_state(state: JobState | None, event: JobEvent) -> Transition:
if state is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
created = JobState(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
return Transition(created, (Notification("authorize"),))
if event.job_id != state.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(state, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if state.status is not JobStatus.RUNNING:
return Transition(state)
failed = replace(
state,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
listeners = [Notification("abort_sessions"), RECONCILE]
if state.workflow_id:
listeners.insert(1, Notification("fail_workflow"))
return Transition(failed, tuple(listeners))
if state.status is JobStatus.RECEIVED:
return _received(state, event)
if state.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(state, status=JobStatus.RUNNING, stage="starting"), (RECONCILE,)
)
if state.status is JobStatus.RUNNING:
return _running(state, event)
raise InvalidTransition(f"{event.type} is invalid while job is {state.status}")
def _received(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(state.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=state.is_pull_request)
except CommandError as exc:
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
state,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
return Transition(queued, (Notification("execute", "jobs"), RECONCILE))
def _running(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(state, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(state, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(state, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(state, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
state,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
listeners = [RECONCILE]
if state.workflow_id:
listeners.append(Notification("fail_workflow"))
return Transition(replace(state, status=status, stage=stage, error=error), tuple(listeners))
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(state: JobState) -> str:
marker = f"<!-- agentci:job id={state.id} -->"
if state.status is JobStatus.SUCCEEDED and state.comment_body:
body = state.comment_body
elif state.status is JobStatus.REJECTED:
body = f"Agent job `{state.id}` was rejected: {state.error}"
elif state.status is JobStatus.FAILED:
body = f"Agent job `{state.id}` failed during `{state.stage}`: {state.error}"
else:
kind = state.kind.value if state.kind else "command"
body = f"Agent job `{state.id}` {state.status.value} (`{kind}`; stage: `{state.stage}`)."
return f"{marker}\n{body}"
+1
View File
@@ -0,0 +1 @@
"""Durable job and workflow engine."""
+271
View File
@@ -0,0 +1,271 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from agentci.engine.events import JobEvent
from agentci.engine.model import (
Job,
JobKind,
JobStatus,
QueueName,
Task,
TaskKind,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import Transition
def now() -> str:
return datetime.now(UTC).isoformat()
def connect(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path, timeout=30)
try:
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
except Exception:
connection.close()
raise
return connection
def initialize(connection: sqlite3.Connection, migrations_dir: Path) -> None:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {row[0] for row in connection.execute("SELECT version FROM schema_migrations")}
for path in sorted(migrations_dir.glob("*.sql")):
version = int(path.name.split("_", 1)[0])
if version in applied:
continue
connection.executescript(path.read_text())
connection.execute(
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
(version, now()),
)
def insert_event(
connection: sqlite3.Connection,
event_id: str,
event: JobEvent,
timestamp: str,
) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), timestamp),
)
def insert_tasks(
connection: sqlite3.Connection,
event_id: str,
transition: Transition,
timestamp: str,
) -> None:
for ordinal, task in enumerate(transition.tasks):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.job.id,
event_id,
ordinal,
task.kind.value,
task.queue.value,
timestamp,
timestamp,
),
)
def insert_job(connection: sqlite3.Connection, job: Job, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_job_values(job), timestamp),
)
def replace_job(
connection: sqlite3.Connection,
job: Job,
previous: Job,
timestamp: str,
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and job.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and job.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_job_values(job)[1:], started, finished, job.id),
)
def optional_job(connection: sqlite3.Connection, job_id: str) -> Job | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return job_from_row(row) if row else None
def required_job(connection: sqlite3.Connection, job_id: str) -> Job:
job = optional_job(connection, job_id)
if job is None:
raise KeyError(f"Unknown job {job_id}")
return job
def job_from_row(row: sqlite3.Row) -> Job:
return Job(
id=row["id"],
kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"],
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
requester=row["requester"],
message=row["message"],
comment_id=row["comment_id"],
delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"],
command_body=row["command_body"],
workflow_id=row["workflow_id"],
status=JobStatus(row["status"]),
stage=row["stage"],
error=row["error"],
runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"],
comment_body=row["comment_body"],
)
def insert_workflow(
connection: sqlite3.Connection,
workflow: Workflow,
timestamp: str,
) -> None:
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id,
workflow.kind.value,
workflow.repo_owner,
workflow.repo_name,
workflow.issue_number,
workflow.pr_number,
workflow.base_sha,
workflow.branch,
str(workflow.workspace_path),
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
workflow.runtime,
timestamp,
timestamp,
),
)
def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
if row is None:
return None
return Workflow(
id=row["id"],
kind=WorkflowKind(row["kind"]),
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
reviewer_session_id=row["reviewer_session_id"],
artifact=row["artifact"],
review_json=row["review_json"],
status=WorkflowStatus(row["status"]),
)
def claim_task(connection: sqlite3.Connection, queue: str) -> Task | None:
connection.execute("BEGIN IMMEDIATE")
fifo = ""
if queue == QueueName.JOBS.value:
fifo = """AND NOT EXISTS (
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
AND earlier.target_key = j.target_key
AND earlier.status IN ('received', 'queued', 'running'))"""
row = connection.execute(
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
ORDER BY {"j.receive_sequence" if queue == QueueName.JOBS.value else "t.id"} LIMIT 1""",
(queue, now()),
).fetchone()
if row is None:
connection.commit()
return None
changed = connection.execute(
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
"WHERE id=? AND status='pending'",
(now(), row["id"]),
)
if changed.rowcount != 1:
connection.rollback()
return None
connection.commit()
return Task(
id=row["id"],
job_id=row["job_id"],
source_event_id=row["source_event_id"],
kind=TaskKind(row["listener"]),
queue=QueueName(row["queue"]),
attempts=row["attempts"] + 1,
)
def _job_values(job: Job) -> tuple[object, ...]:
return (
job.id,
job.kind.value if job.kind else None,
job.target_key,
job.repo_owner,
job.repo_name,
job.issue_number,
job.pr_number,
job.requester,
job.message,
job.comment_id,
job.delivery_id,
job.receive_sequence,
job.command_body,
job.workflow_id,
job.status.value,
job.stage,
job.error,
job.runtime_session_id,
job.accepted_comment_id,
job.comment_body,
)
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from agentci.domain.models import CommandName, JobKind, ParsedCommand from agentci.engine.model import CommandName, JobKind, ParsedCommand
COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$") COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$")
@@ -4,7 +4,7 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from agentci.domain.models import Workflow from agentci.engine.model import Workflow
class Event(BaseModel): class Event(BaseModel):
@@ -4,8 +4,6 @@ from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field
class CommandName(StrEnum): class CommandName(StrEnum):
PLAN = "plan" PLAN = "plan"
@@ -44,43 +42,17 @@ class WorkflowStatus(StrEnum):
FAILED = "failed" FAILED = "failed"
class ReviewSeverity(StrEnum): class TaskKind(StrEnum):
BLOCKING = "blocking" AUTHORIZE = "authorize"
MAJOR = "major" EXECUTE = "execute"
MINOR = "minor" RECONCILE_COMMENT = "reconcile_comment"
FAIL_WORKFLOW = "fail_workflow"
ABORT_SESSIONS = "abort_sessions"
class PlanArtifact(BaseModel): class QueueName(StrEnum):
plan_markdown: str = Field(min_length=1) CONTROL = "control"
JOBS = "jobs"
class DiscussionReply(BaseModel):
markdown: str = Field(min_length=1)
class AgentResult(BaseModel):
summary_markdown: str = Field(min_length=1)
tests: list[str] = Field(default_factory=list)
class ReviewFinding(BaseModel):
severity: ReviewSeverity
title: str
detail: str
location: str | None = None
recommendation: str
class ReviewReport(BaseModel):
summary: str
findings: list[ReviewFinding] = Field(default_factory=list)
@property
def has_serious_findings(self) -> bool:
return any(
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
for finding in self.findings
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -90,7 +62,7 @@ class ParsedCommand:
@dataclass(frozen=True) @dataclass(frozen=True)
class CommandEvent: class IncomingCommand:
delivery_id: str delivery_id: str
comment_id: int comment_id: int
repo_owner: str repo_owner: str
@@ -110,26 +82,35 @@ class CommandEvent:
return f"{self.repo_owner}/{self.repo_name}:{target}" return f"{self.repo_owner}/{self.repo_name}:{target}"
@dataclass @dataclass(frozen=True)
class Job: class Job:
id: str id: str
kind: JobKind
target_key: str target_key: str
repo_owner: str repo_owner: str
repo_name: str repo_name: str
issue_number: int issue_number: int
pr_number: int | None pr_number: int | None
requester: str requester: str
message: str
comment_id: int comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None workflow_id: str | None = None
status: JobStatus = JobStatus.QUEUED
stage: str = "queued"
accepted_comment_id: int | None = None
runtime_session_id: str | None = None runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass @dataclass(frozen=True)
class Workflow: class Workflow:
id: str id: str
kind: WorkflowKind kind: WorkflowKind
@@ -146,3 +127,19 @@ class Workflow:
artifact: str | None = None artifact: str | None = None
review_json: str | None = None review_json: str | None = None
status: WorkflowStatus = WorkflowStatus.ACTIVE status: WorkflowStatus = WorkflowStatus.ACTIVE
@dataclass(frozen=True)
class TaskRequest:
kind: TaskKind
queue: QueueName
@dataclass(frozen=True)
class Task:
id: int
job_id: str
source_event_id: str
kind: TaskKind
queue: QueueName
attempts: int
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
from agentci.engine.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Job, JobStatus, QueueName, TaskKind, TaskRequest
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Transition:
job: Job
tasks: tuple[TaskRequest, ...] = ()
RECONCILE = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
def reduce_job(current: Job | None, event: JobEvent) -> Transition:
if current is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
job = Job(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
task = TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL)
return Transition(job, (task,))
if event.job_id != current.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(current, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if current.status is not JobStatus.RUNNING:
return Transition(current)
failed = replace(
current,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
tasks = [TaskRequest(TaskKind.ABORT_SESSIONS, QueueName.CONTROL), RECONCILE]
if current.workflow_id:
tasks.insert(1, TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
return Transition(failed, tuple(tasks))
if current.status is JobStatus.RECEIVED:
return _received(current, event)
if current.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(current, status=JobStatus.RUNNING, stage="starting"),
(RECONCILE,),
)
if current.status is JobStatus.RUNNING:
return _running(current, event)
raise InvalidTransition(f"{event.type} is invalid while job is {current.status}")
def _received(current: Job, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(current, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(current.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=current.is_pull_request)
except CommandError as exc:
return Transition(
replace(current, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
current,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
tasks = (
TaskRequest(TaskKind.EXECUTE, QueueName.JOBS),
RECONCILE,
)
return Transition(queued, tasks)
def _running(current: Job, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(current, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(current, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(current, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(current, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
current,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
tasks = [RECONCILE]
if current.workflow_id:
tasks.append(TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
return Transition(
replace(current, status=status, stage=stage, error=error),
tuple(tasks),
)
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(job: Job) -> str:
marker = f"<!-- agentci:job id={job.id} -->"
if job.status is JobStatus.SUCCEEDED and job.comment_body:
body = job.comment_body
elif job.status is JobStatus.REJECTED:
body = f"Agent job `{job.id}` was rejected: {job.error}"
elif job.status is JobStatus.FAILED:
body = f"Agent job `{job.id}` failed during `{job.stage}`: {job.error}"
else:
kind = job.kind.value if job.kind else "command"
body = f"Agent job `{job.id}` {job.status.value} (`{kind}`; stage: `{job.stage}`)."
return f"{marker}\n{body}"
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
import logging
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TypeVar
from uuid import UUID, uuid5
from agentci.engine import _sqlite
from agentci.engine.events import CommandReceived, JobEvent, WorkflowCreated
from agentci.engine.model import (
IncomingCommand,
Job,
QueueName,
Task,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import reduce_job
T = TypeVar("T")
log = logging.getLogger(__name__)
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ApplyResult:
job: Job
duplicate: bool
class Repository:
def __init__(self, database_path: Path, migrations_dir: Path | None = None) -> None:
self.database_path = database_path
self.migrations_dir = migrations_dir or Path(__file__).parent.parent / "migrations"
async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True)
try:
await self._run(lambda connection: _sqlite.initialize(connection, self.migrations_dir))
except Exception:
log.exception(
"database initialization failed",
extra={"operation": "database.initialize"},
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
async def accept(self, command: IncomingCommand) -> ApplyResult:
job_id = str(uuid5(JOB_NAMESPACE, command.delivery_id))
event_id = f"delivery:{command.delivery_id}"
def operation(connection: sqlite3.Connection) -> ApplyResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
job = _sqlite.required_job(connection, duplicate["job_id"])
connection.commit()
return ApplyResult(job, True)
sequence = connection.execute(
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
).fetchone()[0]
event = CommandReceived(
job_id=job_id,
delivery_id=command.delivery_id,
receive_sequence=sequence,
command_body=command.body,
target_key=command.target_key,
repo_owner=command.repo_owner,
repo_name=command.repo_name,
issue_number=command.issue_number,
pr_number=command.pr_number,
requester=command.requester,
comment_id=command.comment_id,
)
transition = reduce_job(None, event)
timestamp = _sqlite.now()
_sqlite.insert_event(connection, event_id, event, timestamp)
_sqlite.insert_job(connection, transition.job, timestamp)
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return ApplyResult(transition.job, False)
return await self._run(operation)
async def apply(self, event_id: str, event: JobEvent) -> ApplyResult:
def operation(connection: sqlite3.Connection) -> ApplyResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
existing_job_id = duplicate["job_id"]
if existing_job_id != event.job_id:
raise RuntimeError(
f"Event ID {event_id!r} belongs to job {existing_job_id!r}, "
f"not {event.job_id!r}"
)
job = _sqlite.required_job(connection, existing_job_id)
connection.commit()
return ApplyResult(job, True)
current = _sqlite.required_job(connection, event.job_id)
transition = reduce_job(current, event)
timestamp = _sqlite.now()
_sqlite.insert_event(connection, event_id, event, timestamp)
if isinstance(event, WorkflowCreated):
_sqlite.insert_workflow(connection, event.workflow, timestamp)
_sqlite.replace_job(connection, transition.job, current, timestamp)
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return ApplyResult(transition.job, False)
return await self._run(operation)
async def get_job(self, job_id: str) -> Job | None:
return await self._run(lambda connection: _sqlite.optional_job(connection, job_id))
async def claim_task(self, queue: QueueName | str) -> Task | None:
queue_name = queue.value if isinstance(queue, QueueName) else queue
return await self._run(lambda connection: _sqlite.claim_task(connection, queue_name))
async def complete_task(self, task_id: int) -> None:
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
(_sqlite.now(), task_id),
)
)
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
delay = min(2 ** min(attempts, 8), 300)
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
(available, error[:1000], task_id),
)
)
async def recover_tasks(self) -> None:
def operation(connection: sqlite3.Connection) -> None:
with connection:
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND queue='control'"""
)
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status='queued')"""
)
connection.execute(
"""UPDATE listener_tasks SET status='failed', finished_at=?,
error='Service restarted after execution began'
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status<>'queued')""",
(_sqlite.now(),),
)
await self._run(operation)
async def running_jobs(self) -> list[Job]:
return await self._run(
lambda connection: [
_sqlite.job_from_row(row)
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
]
)
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
return await self._run(
lambda connection: {
value
for row in connection.execute(
"SELECT accepted_comment_id, started_comment_id FROM jobs "
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
(owner, repo, issue),
)
for value in row
if value is not None
}
)
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute("SELECT * FROM workflows WHERE id=?", (workflow_id,)).fetchone()
)
)
async def save_workflow(self, workflow: Workflow) -> None:
def operation(connection: sqlite3.Connection) -> None:
updated = connection.execute(
"""UPDATE workflows SET pr_number=?, branch=?, primary_session_id=?,
reviewer_session_id=?, artifact=?, review_json=?, status=?, updated_at=?
WHERE id=?""",
(
workflow.pr_number,
workflow.branch,
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
_sqlite.now(),
workflow.id,
),
)
if updated.rowcount != 1:
raise KeyError(f"Unknown workflow {workflow.id}")
await self._run(operation)
async def latest_workflow(
self,
owner: str,
repo: str,
issue: int,
kind: WorkflowKind,
) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=?
AND kind=? AND status=?
ORDER BY created_at DESC LIMIT 1""",
(owner, repo, issue, kind.value, WorkflowStatus.COMPLETED.value),
).fetchone()
)
)
async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=?
ORDER BY created_at DESC LIMIT 1""",
(owner, repo, pr, WorkflowKind.IMPLEMENT.value),
).fetchone()
)
)
async def implementation_workflows(self, owner: str, repo: str, issue: int) -> list[Workflow]:
return await self._run(
lambda connection: [
workflow
for row in connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=?
AND pr_number IS NOT NULL
ORDER BY created_at DESC""",
(owner, repo, issue, WorkflowKind.IMPLEMENT.value),
)
if (workflow := _sqlite.workflow_from_row(row)) is not None
]
)
async def fail_job_workflow(self, job_id: str) -> None:
await self._run(
lambda connection: connection.execute(
"""UPDATE workflows SET status=?, updated_at=?
WHERE id=(SELECT workflow_id FROM jobs WHERE id=?)
AND status=?""",
(
WorkflowStatus.FAILED.value,
_sqlite.now(),
job_id,
WorkflowStatus.ACTIVE.value,
),
)
)
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
connection = _sqlite.connect(self.database_path)
try:
with connection:
return operation(connection)
finally:
connection.close()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from agentci.engine.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Workflow
from agentci.engine.repository import Repository
type ReportEvent = JobProgress | RuntimeSessionLinked | WorkflowCreated | WorkflowLinked
class JobRun:
def __init__(self, repository: Repository, job_id: str, task_id: int) -> None:
self.repository = repository
self.job_id = job_id
self.task_id = task_id
self._sequence = 0
async def stage(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
async def _emit(self, event: ReportEvent) -> None:
self._sequence += 1
event_id = f"task:{self.task_id}:report:{self._sequence}"
await self.repository.apply(event_id, event)
@@ -13,7 +13,7 @@ class GitError(RuntimeError):
pass pass
class GitClient: class Git:
def __init__( def __init__(
self, self,
*, *,
@@ -31,9 +31,6 @@ class GitClient:
self.commit_name = commit_name self.commit_name = commit_name
self.commit_email = commit_email self.commit_email = commit_email
def clone_url(self, owner: str, repo: str) -> str:
return f"{self.gitea_url}/{owner}/{repo}.git"
async def clone( async def clone(
self, self,
owner: str, owner: str,
@@ -47,7 +44,7 @@ class GitClient:
"--branch", "--branch",
branch, branch,
"--single-branch", "--single-branch",
self.clone_url(owner, repo), f"{self.gitea_url}/{owner}/{repo}.git",
str(destination), str(destination),
cwd=destination.parent, cwd=destination.parent,
authenticated=True, authenticated=True,
@@ -85,6 +82,7 @@ class GitClient:
"-m", "-m",
message, message,
cwd=workspace, cwd=workspace,
command_name="commit",
) )
return await self.current_sha(workspace) return await self.current_sha(workspace)
@@ -101,8 +99,10 @@ class GitClient:
*args: str, *args: str,
cwd: Path, cwd: Path,
authenticated: bool = False, authenticated: bool = False,
command_name: str | None = None,
) -> str: ) -> str:
operation = f"git.{args[0]}" command_name = command_name or args[0]
operation = f"git.{command_name}"
started = monotonic() started = monotonic()
log.info("git step started", extra={"operation": operation}) log.info("git step started", extra={"operation": operation})
environment = os.environ.copy() environment = os.environ.copy()
@@ -130,14 +130,14 @@ class GitClient:
"git step could not start", "git step could not start",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
) )
raise GitError(f"Could not run git {args[0]}: {exc}") from exc raise GitError(f"Could not run git {command_name}: {exc}") from exc
if process.returncode: if process.returncode:
detail = stderr.decode(errors="replace").strip() detail = stderr.decode(errors="replace").strip()
log.error( log.error(
"git step failed", "git step failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
) )
raise GitError(f"git {args[0]} failed: {detail[-1000:]}") raise GitError(f"git {command_name} failed: {detail[-1000:]}")
log.info( log.info(
"git step completed", "git step completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
@@ -2,33 +2,69 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass
from time import monotonic from time import monotonic
from typing import Any from typing import Any
import httpx import httpx
from agentci.adapters.gitea_models import (
CommentInfo,
IssueInfo,
PullRequestInfo,
RepositoryInfo,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclass(frozen=True)
class IssueInfo:
number: int
title: str
body: str
state: str
@dataclass(frozen=True)
class CommentInfo:
id: int
author: str
body: str
created_at: str
@dataclass(frozen=True)
class PullRequestInfo:
number: int
title: str
body: str
state: str
merged: bool
base_branch: str
head_branch: str
head_sha: str
head_owner: str
head_repo: str
@property
def is_open(self) -> bool:
return self.state == "open" and not self.merged
class GiteaError(RuntimeError): class GiteaError(RuntimeError):
pass pass
class GiteaClient: class Gitea:
def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None: def __init__(
self,
base_url: str,
token: str,
*,
retries: int = 3,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.retries = retries self.retries = retries
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1", base_url=f"{self.base_url}/api/v1",
headers={"Authorization": f"token {token}", "Accept": "application/json"}, headers={"Authorization": f"token {token}", "Accept": "application/json"},
timeout=30, timeout=30,
transport=transport,
) )
async def close(self) -> None: async def close(self) -> None:
@@ -41,14 +77,9 @@ class GiteaClient:
permission = str(response.json().get("permission", "")).lower() permission = str(response.json().get("permission", "")).lower()
return permission in {"write", "admin", "owner"} return permission in {"write", "admin", "owner"}
async def repository(self, owner: str, repo: str) -> RepositoryInfo: async def default_branch(self, owner: str, repo: str) -> str:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json() data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return RepositoryInfo( return str(data["default_branch"])
owner=owner,
name=repo,
full_name=data.get("full_name", f"{owner}/{repo}"),
default_branch=data["default_branch"],
)
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo: async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json() data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
@@ -2,6 +2,8 @@ from __future__ import annotations
from fastapi import APIRouter, Request, Response, status from fastapi import APIRouter, Request, Response, status
from agentci.runtime import Runtime
router = APIRouter() router = APIRouter()
@@ -12,7 +14,8 @@ async def live() -> dict[str, str]:
@router.get("/health/ready") @router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]: async def ready(request: Request, response: Response) -> dict[str, str]:
if not await request.app.state.container.opencode.ready(): runtime: Runtime = request.app.state.runtime
if not await runtime.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "opencode provider is not connected"} return {"status": "not-ready", "reason": "opencode provider is not connected"}
return {"status": "ready"} return {"status": "ready"}
+8 -3
View File
@@ -37,9 +37,14 @@ class JsonFormatter(logging.Formatter):
try: try:
return json.dumps(payload, ensure_ascii=False) return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError): except (TypeError, ValueError):
payload["message"] = "Log record could not be serialized" fallback = {
payload["exception"] = traceback.format_exc() "timestamp": payload["timestamp"],
return json.dumps(payload, ensure_ascii=False) "level": payload["level"],
"logger": payload["logger"],
"message": "Log record could not be serialized",
"exception": traceback.format_exc(),
}
return json.dumps(fallback, ensure_ascii=False)
def configure_logging() -> None: def configure_logging() -> None:
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
from contextlib import suppress
from pathlib import Path from pathlib import Path
from time import monotonic from time import monotonic
from typing import Any, TypeVar from typing import Any, TypeVar
@@ -9,16 +11,7 @@ from typing import Any, TypeVar
import httpx import httpx
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from agentci.adapters.codegraph import CodeGraphClient from agentci.codegraph import CodeGraph
from agentci.adapters.opencode_support import (
api_contract_ready,
directory_headers,
elapsed_ms,
error_message,
load_schema,
model_parts,
models_ready,
)
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -28,7 +21,7 @@ class OpenCodeError(RuntimeError):
pass pass
class OpenCodeClient: class OpenCode:
def __init__( def __init__(
self, self,
*, *,
@@ -39,7 +32,7 @@ class OpenCodeClient:
health_directory: Path, health_directory: Path,
required_models: tuple[tuple[str, str | None], ...], required_models: tuple[tuple[str, str | None], ...],
timeout_seconds: int, timeout_seconds: int,
codegraph: CodeGraphClient | None = None, codegraph: CodeGraph | None = None,
transport: httpx.AsyncBaseTransport | None = None, transport: httpx.AsyncBaseTransport | None = None,
) -> None: ) -> None:
self.schemas_dir = schemas_dir self.schemas_dir = schemas_dir
@@ -48,8 +41,9 @@ class OpenCodeClient:
(*model_parts(model), variant) for model, variant in required_models (*model_parts(model), variant) for model, variant in required_models
} }
self._contract_valid: bool | None = None self._contract_valid: bool | None = None
self._readiness_task: asyncio.Task[bool] | None = None
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self.codegraph = codegraph or CodeGraphClient() self.codegraph = codegraph or CodeGraph()
self._active_sessions: dict[str, Path] = {} self._active_sessions: dict[str, Path] = {}
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=base_url.rstrip("/"), base_url=base_url.rstrip("/"),
@@ -59,11 +53,27 @@ class OpenCodeClient:
) )
async def close(self) -> None: async def close(self) -> None:
if self._readiness_task is not None and not self._readiness_task.done():
self._readiness_task.cancel()
with suppress(asyncio.CancelledError):
await self._readiness_task
self._readiness_task = None
for session_id, workspace in tuple(self._active_sessions.items()): for session_id, workspace in tuple(self._active_sessions.items()):
await self.abort(session_id, workspace, best_effort=True) await self.abort(session_id, workspace, best_effort=True)
await self.client.aclose() await self.client.aclose()
async def ready(self) -> bool: async def ready(self) -> bool:
task = self._readiness_task
if task is None:
task = asyncio.create_task(self._check_ready())
self._readiness_task = task
try:
return await asyncio.shield(task)
finally:
if task.done() and self._readiness_task is task:
self._readiness_task = None
async def _check_ready(self) -> bool:
try: try:
health = await self.client.get("/global/health", timeout=10) health = await self.client.get("/global/health", timeout=10)
health.raise_for_status() health.raise_for_status()
@@ -85,28 +95,6 @@ class OpenCodeClient:
except (httpx.HTTPError, TypeError, ValueError): except (httpx.HTTPError, TypeError, ValueError):
return False return False
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
session_id = await self.create_session(workspace, schema_name)
result = await self.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
return session_id, result
async def create_session(self, workspace: Path, title: str) -> str: async def create_session(self, workspace: Path, title: str) -> str:
response = await self._request( response = await self._request(
"POST", "POST",
@@ -143,7 +131,10 @@ class OpenCodeClient:
result_type=result_type, result_type=result_type,
) )
except (asyncio.CancelledError, OpenCodeError): except (asyncio.CancelledError, OpenCodeError):
await asyncio.shield(self.abort(session_id, workspace)) try:
await asyncio.shield(self.abort(session_id, workspace))
except OpenCodeError as exc:
log.warning("OpenCode failed session could not be aborted", exc_info=exc)
raise raise
finally: finally:
self._active_sessions.pop(session_id, None) self._active_sessions.pop(session_id, None)
@@ -186,7 +177,6 @@ class OpenCodeClient:
json=payload, json=payload,
) )
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
await self.abort(session_id, workspace)
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds" message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
raise OpenCodeError(message) from exc raise OpenCodeError(message) from exc
info = response.get("info") info = response.get("info")
@@ -213,7 +203,10 @@ class OpenCodeClient:
raise OpenCodeError("OpenCode did not return a valid result") raise OpenCodeError("OpenCode did not return a valid result")
async def _request( async def _request(
self, method: str, path: str, *, self,
method: str,
path: str,
*,
workspace: Path, workspace: Path,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -248,3 +241,91 @@ class OpenCodeClient:
log.warning("OpenCode session could not be aborted", exc_info=exc) log.warning("OpenCode session could not be aborted", exc_info=exc)
return return
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
try:
value = json.loads((schemas_dir / name).read_text())
except (OSError, ValueError) as exc:
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"Result schema {name} is not a JSON object")
return value
def api_contract_ready(document: object) -> bool:
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
return False
paths = document["paths"]
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
for path, method in fixed.items():
operations = paths.get(path)
if not isinstance(operations, dict) or method not in operations:
return False
session_paths = [
path
for path, operations in paths.items()
if isinstance(path, str) and isinstance(operations, dict) and path.startswith("/session/{")
]
has_message = any(path.endswith("/message") and "post" in paths[path] for path in session_paths)
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
return has_message and has_abort
def models_ready(payload: object, requirements: set[tuple[str, str, str | None]]) -> bool:
if not isinstance(payload, dict):
return False
connected_value = payload.get("connected")
provider_values = payload.get("all")
if not isinstance(connected_value, list) or not all(
isinstance(item, str) for item in connected_value
):
return False
if not isinstance(provider_values, list):
return False
connected = set(connected_value)
providers: dict[str, dict[str, Any]] = {}
for item in provider_values:
if not isinstance(item, dict):
continue
provider_id = item.get("id")
if isinstance(provider_id, str) and isinstance(item.get("models"), dict):
providers[provider_id] = item
for provider_id, model_id, variant in requirements:
provider = providers.get(provider_id)
if provider_id not in connected or not isinstance(provider, dict):
return False
model: Any = provider["models"].get(model_id)
if not isinstance(model, dict) or model.get("status") == "deprecated":
return False
capabilities = model.get("capabilities")
if not isinstance(capabilities, dict) or capabilities.get("toolcall") is not True:
return False
if variant:
variants = model.get("variants")
if not isinstance(variants, dict) or variant not in variants:
return False
return True
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from agentci.domain.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import Workflow
from agentci.state_machine import StateMachine
class NullReporter:
final_body: str | None = None
async def progress(self, _stage: str) -> None:
pass
async def create_workflow(self, _workflow: Workflow, _stage: str) -> None:
pass
async def link_workflow(self, _workflow_id: str, _stage: str) -> None:
pass
async def link_runtime_session(self, _session_id: str) -> None:
pass
def finish(self, body: str) -> None:
self.final_body = body
_current: ContextVar[JobReporter | NullReporter | None] = ContextVar(
"job_reporter", default=None
)
class JobReporter:
def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None:
self.host = host
self.job_id = job_id
self.task_id = task_id
self.sequence = 0
self.final_body: str | None = None
async def progress(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_runtime_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
def finish(self, body: str) -> None:
self.final_body = body
async def _emit(self, event) -> None:
self.sequence += 1
await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event)
def bind_reporter(
reporter: JobReporter,
) -> Token[JobReporter | NullReporter | None]:
return _current.set(reporter)
def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None:
_current.reset(token)
def reporter() -> JobReporter | NullReporter:
return _current.get() or NullReporter()
@@ -4,48 +4,45 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings from agentci.config import Settings
from agentci.development import DevelopmentEnvironment
from agentci.engine.repository import Repository
from agentci.git import Git
from agentci.gitea import Gitea
from agentci.opencode import OpenCode
from agentci.prompts import PromptLibrary from agentci.prompts import PromptLibrary
from agentci.state_machine import StateMachine
from agentci.worker import Worker from agentci.worker import Worker
from agentci.workflows.common import Dependencies from agentci.workflows.services import WorkflowServices
from agentci.workflows.context import ContextBuilder
from agentci.workflows.dispatcher import Dispatcher
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class Container: class Runtime:
settings: Settings settings: Settings
storage: Storage repository: Repository
gitea: GiteaClient gitea: Gitea
git: GitClient git: Git
opencode: OpenCodeClient opencode: OpenCode
state_machine: StateMachine
worker: Worker worker: Worker
async def close(self) -> None: async def close(self) -> None:
log.info("container shutdown started", extra={"operation": "container.close"}) log.info("runtime shutdown started", extra={"operation": "runtime.close"})
await self.opencode.close() try:
await self.gitea.close() await self.opencode.close()
log.info("container shutdown completed", extra={"operation": "container.close"}) finally:
await self.gitea.close()
log.info("runtime shutdown completed", extra={"operation": "runtime.close"})
async def build_container(settings: Settings) -> Container: async def build_runtime(settings: Settings) -> Runtime:
log.info("container initialization started", extra={"operation": "container.build"}) log.info("runtime initialization started", extra={"operation": "runtime.build"})
package_dir = Path(__file__).parent package_dir = Path(__file__).parent
settings.data_dir.mkdir(parents=True, exist_ok=True) settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.workspaces_dir.mkdir(parents=True, exist_ok=True) settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
storage = Storage(settings.database_path, package_dir / "migrations") repository = Repository(settings.database_path, package_dir / "migrations")
await storage.initialize() await repository.initialize()
state_machine = StateMachine(storage) gitea = Gitea(settings.gitea_url, settings.gitea_token)
gitea = GiteaClient(settings.gitea_url, settings.gitea_token) git = Git(
git = GitClient(
gitea_url=settings.gitea_url, gitea_url=settings.gitea_url,
username=settings.bot_username, username=settings.bot_username,
token=settings.gitea_token, token=settings.gitea_token,
@@ -53,7 +50,7 @@ async def build_container(settings: Settings) -> Container:
commit_name=settings.bot_name, commit_name=settings.bot_name,
commit_email=settings.bot_email, commit_email=settings.bot_email,
) )
opencode = OpenCodeClient( opencode = OpenCode(
base_url=settings.opencode_url, base_url=settings.opencode_url,
username=settings.opencode_server_username, username=settings.opencode_server_username,
password=settings.opencode_server_password, password=settings.opencode_server_password,
@@ -67,8 +64,6 @@ async def build_container(settings: Settings) -> Container:
), ),
timeout_seconds=settings.turn_timeout_seconds, timeout_seconds=settings.turn_timeout_seconds,
) )
prompts = PromptLibrary()
context = ContextBuilder(gitea, storage)
development = DevelopmentEnvironment( development = DevelopmentEnvironment(
scripts=settings.install_scripts, scripts=settings.install_scripts,
scripts_dir=settings.install_scripts_dir, scripts_dir=settings.install_scripts_dir,
@@ -77,28 +72,25 @@ async def build_container(settings: Settings) -> Container:
python_version=settings.python_version, python_version=settings.python_version,
dotnet_channel=settings.dotnet_channel, dotnet_channel=settings.dotnet_channel,
) )
dependencies = Dependencies( services = WorkflowServices(
settings=settings, settings=settings,
storage=storage, repository=repository,
gitea=gitea, gitea=gitea,
git=git, git=git,
opencode=opencode, opencode=opencode,
prompts=prompts, prompts=PromptLibrary(),
context=context,
development=development, development=development,
) )
dispatcher = Dispatcher(dependencies)
worker = Worker( worker = Worker(
storage=storage, repository=repository,
state_machine=state_machine,
gitea=gitea, gitea=gitea,
opencode=opencode, opencode=opencode,
dispatcher=dispatcher, services=services,
poll_seconds=settings.worker_poll_seconds, poll_seconds=settings.worker_poll_seconds,
max_concurrent_jobs=settings.max_concurrent_jobs, max_concurrent_jobs=settings.max_concurrent_jobs,
workspaces_dir=settings.workspaces_dir, workspaces_dir=settings.workspaces_dir,
bot_username=settings.bot_username, bot_username=settings.bot_username,
) )
container = Container(settings, storage, gitea, git, opencode, state_machine, worker) runtime = Runtime(settings, repository, gitea, git, opencode, worker)
log.info("container initialization completed", extra={"operation": "container.build"}) log.info("runtime initialization completed", extra={"operation": "runtime.build"})
return container return runtime
-33
View File
@@ -1,33 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID, uuid5
from agentci.adapters.job_store import EvolveResult, JobStore
from agentci.domain.events import JobEvent
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ReceiveResult:
state: JobState
duplicate: bool
class StateMachine:
def __init__(self, store: JobStore) -> None:
self.store = store
async def receive(self, incoming: CommandEvent) -> ReceiveResult:
job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id))
result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming)
return ReceiveResult(result.state, result.duplicate)
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
return await self.store.evolve(event_id, event)
async def get(self, job_id: str) -> JobState | None:
return await self.store.get_job_state(job_id)
@@ -8,7 +8,8 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from agentci.domain.models import CommandEvent from agentci.engine.model import IncomingCommand
from agentci.runtime import Runtime
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -21,10 +22,10 @@ SUPPORTED_EVENTS = {
@router.post("/webhooks/gitea") @router.post("/webhooks/gitea")
async def webhook(request: Request) -> Response: async def webhook(request: Request) -> Response:
container = request.app.state.container runtime: Runtime = request.app.state.runtime
body = await request.body() body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "") signature = request.headers.get("X-Gitea-Signature", "")
if not valid_signature(container.settings.webhook_secret, body, signature): if not valid_signature(runtime.settings.webhook_secret, body, signature):
log.warning( log.warning(
"webhook signature rejected", "webhook signature rejected",
extra={"operation": "webhook.verify", "path": request.url.path}, extra={"operation": "webhook.verify", "path": request.url.path},
@@ -41,21 +42,23 @@ async def webhook(request: Request) -> Response:
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
try: try:
payload = json.loads(body) payload = json.loads(body)
if not isinstance(payload, dict):
raise ValueError("Webhook payload must be a JSON object")
event = _event_from_payload( event = _event_from_payload(
request.headers.get("X-Gitea-Delivery", ""), request.headers.get("X-Gitea-Delivery", ""),
payload, payload,
) )
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: except (KeyError, TypeError, ValueError) as exc:
log.warning( log.warning(
"webhook payload rejected", "webhook payload rejected",
extra={"operation": "webhook.parse", "stage": event_name}, extra={"operation": "webhook.parse", "stage": event_name},
exc_info=exc, exc_info=exc,
) )
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
if event is None or event.requester.casefold() == container.settings.bot_username.casefold(): if event is None or event.requester.casefold() == runtime.settings.bot_username.casefold():
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name}) log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
return await _handle_command(container, event) return await _handle_command(runtime, event)
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool: def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
@@ -63,7 +66,7 @@ def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
return bool(signature) and hmac.compare_digest(expected, signature) return bool(signature) and hmac.compare_digest(expected, signature)
async def _handle_command(container: Any, event: CommandEvent) -> Response: async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
extra = {"operation": "command.handle", "target": event.target_key} extra = {"operation": "command.handle", "target": event.target_key}
if not event.body.strip().startswith("/agent"): if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -71,25 +74,25 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
log.info("agent command received", extra=extra) log.info("agent command received", extra=extra)
try: try:
result = await container.state_machine.receive(event) result = await runtime.repository.accept(event)
except Exception: except Exception:
log.exception("could not persist command", extra=extra) log.exception("could not persist command", extra=extra)
raise raise
if result.duplicate: if result.duplicate:
log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id}) log.info("duplicate command ignored", extra={**extra, "job_id": result.job.id})
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
log.info( log.info(
"agent command persisted", "agent command persisted",
extra={ extra={
**extra, **extra,
"job_id": result.state.id, "job_id": result.job.id,
"receive_sequence": result.state.receive_sequence, "receive_sequence": result.job.receive_sequence,
}, },
) )
return Response(status_code=status.HTTP_202_ACCEPTED) return Response(status_code=status.HTTP_202_ACCEPTED)
def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEvent | None: def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> IncomingCommand | None:
if payload.get("action") != "created": if payload.get("action") != "created":
return None return None
comment = payload["comment"] comment = payload["comment"]
@@ -103,7 +106,7 @@ def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEve
if target is None: if target is None:
raise ValueError("Comment payload has no issue or pull request") raise ValueError("Comment payload has no issue or pull request")
number = int(target["number"]) number = int(target["number"])
return CommandEvent( return IncomingCommand(
delivery_id=delivery_id, delivery_id=delivery_id,
comment_id=int(comment["id"]), comment_id=int(comment["id"]),
repo_owner=owner_name, repo_owner=owner_name,
+83 -82
View File
@@ -5,11 +5,7 @@ import logging
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from agentci.adapters.gitea import GiteaClient from agentci.engine.events import (
from agentci.adapters.job_store import ListenerTask
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.domain.events import (
CommentLinked, CommentLinked,
JobCompleted, JobCompleted,
JobFailed, JobFailed,
@@ -18,15 +14,18 @@ from agentci.domain.events import (
PermissionGranted, PermissionGranted,
ServiceRestarted, ServiceRestarted,
) )
from agentci.domain.events import ( from agentci.engine.events import (
JobRejected as RejectedEvent, JobRejected as RejectedEvent,
) )
from agentci.domain.models import JobStatus from agentci.engine.model import Job, JobStatus, QueueName, Task, TaskKind
from agentci.domain.state_machine import JobState, render_job_comment from agentci.engine.reducer import render_job_comment
from agentci.reporting import JobReporter, bind_reporter, reset_reporter from agentci.engine.repository import Repository
from agentci.state_machine import StateMachine from agentci.engine.run import JobRun
from agentci.workflows.common import JobRejected from agentci.gitea import Gitea
from agentci.workflows.dispatcher import Dispatcher from agentci.opencode import OpenCode
from agentci.workflows.dispatch import dispatch
from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -35,21 +34,19 @@ class Worker:
def __init__( def __init__(
self, self,
*, *,
storage: Storage, repository: Repository,
state_machine: StateMachine, gitea: Gitea,
gitea: GiteaClient, opencode: OpenCode,
opencode: OpenCodeClient, services: WorkflowServices,
dispatcher: Dispatcher,
poll_seconds: float, poll_seconds: float,
max_concurrent_jobs: int, max_concurrent_jobs: int,
workspaces_dir: Path, workspaces_dir: Path,
bot_username: str, bot_username: str,
) -> None: ) -> None:
self.storage = storage self.repository = repository
self.host = state_machine
self.gitea = gitea self.gitea = gitea
self.opencode = opencode self.opencode = opencode
self.dispatcher = dispatcher self.services = services
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
self.max_concurrent_jobs = max_concurrent_jobs self.max_concurrent_jobs = max_concurrent_jobs
self.workspaces_dir = workspaces_dir self.workspaces_dir = workspaces_dir
@@ -58,16 +55,16 @@ class Worker:
async def run(self, stop: asyncio.Event) -> None: async def run(self, stop: asyncio.Event) -> None:
await self._recover() await self._recover()
await asyncio.gather( await asyncio.gather(
self._loop("control", stop), self._loop(QueueName.CONTROL, stop),
*(self._loop("jobs", stop) for _ in range(self.max_concurrent_jobs)), *(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
) )
async def _loop(self, queue: str, stop: asyncio.Event) -> None: async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
while not stop.is_set(): while not stop.is_set():
if queue == "jobs" and not await self.opencode.ready(): if queue is QueueName.JOBS and not await self.opencode.ready():
await self._wait(stop) await self._wait(stop)
continue continue
task = await self.storage.claim_task(queue) task = await self.repository.claim_task(queue)
if task is None: if task is None:
await self._wait(stop) await self._wait(stop)
continue continue
@@ -78,83 +75,85 @@ class Worker:
except Exception as exc: except Exception as exc:
log.exception( log.exception(
"listener failed", "listener failed",
extra={"task_id": task.id, "listener": task.listener, "queue": queue}, extra={
"task_id": task.id,
"listener": task.kind.value,
"queue": queue.value,
},
) )
await self.storage.retry_task(task.id, task.attempts, _safe_error(exc)) await self.repository.retry_task(task.id, task.attempts, _safe_error(exc))
else: else:
await self.storage.complete_task(task.id) await self.repository.complete_task(task.id)
async def _handle(self, task: ListenerTask) -> None: async def _handle(self, task: Task) -> None:
state = await self.host.get(task.job_id) job = await self.repository.get_job(task.job_id)
if state is None: if job is None:
return return
if task.listener == "authorize": match task.kind:
await self._authorize(task, state) case TaskKind.AUTHORIZE:
elif task.listener == "execute": await self._authorize(task, job)
await self._execute(task, state) case TaskKind.EXECUTE:
elif task.listener == "reconcile_comment": await self._execute(task, job)
await self._reconcile(task, state) case TaskKind.RECONCILE_COMMENT:
elif task.listener == "fail_workflow": await self._reconcile(task, job)
await self.storage.fail_job_workflow(state.id) case TaskKind.FAIL_WORKFLOW:
elif task.listener == "abort_sessions": await self.repository.fail_job_workflow(job.id)
await self._abort_job_sessions(state) case TaskKind.ABORT_SESSIONS:
else: await self._abort_job_sessions(job)
raise RuntimeError(f"Unknown listener {task.listener}") case _:
raise RuntimeError(f"Unknown task kind {task.kind}")
async def _authorize(self, task: ListenerTask, state: JobState) -> None: async def _authorize(self, task: Task, job: Job) -> None:
if state.status is not JobStatus.RECEIVED: if job.status is not JobStatus.RECEIVED:
return return
permitted = await self.gitea.has_write_permission( permitted = await self.gitea.has_write_permission(
state.repo_owner, state.repo_name, state.requester job.repo_owner, job.repo_name, job.requester
) )
event = ( event = (
PermissionGranted(job_id=state.id) PermissionGranted(job_id=job.id)
if permitted if permitted
else PermissionDenied(job_id=state.id) else PermissionDenied(job_id=job.id)
) )
outcome = "permission-granted" if permitted else "permission-denied" outcome = "permission-granted" if permitted else "permission-denied"
await self.host.evolve(f"task:{task.id}:{outcome}", event) await self.repository.apply(f"task:{task.id}:{outcome}", event)
async def _execute(self, task: ListenerTask, state: JobState) -> None: async def _execute(self, task: Task, job: Job) -> None:
if state.status is JobStatus.RUNNING: if job.status is JobStatus.RUNNING:
await self.host.evolve( await self.repository.apply(
f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id) f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
) )
return return
if state.status is not JobStatus.QUEUED: if job.status is not JobStatus.QUEUED:
return return
result = await self.host.evolve( result = await self.repository.apply(
f"task:{task.id}:started", JobStarted(job_id=state.id) f"task:{task.id}:started", JobStarted(job_id=job.id)
) )
running = result.state running = result.job
reporter = JobReporter(self.host, state.id, task.id) run = JobRun(self.repository, job.id, task.id)
token = bind_reporter(reporter)
try: try:
await self.dispatcher.dispatch(running) body = await dispatch(running, run, self.services)
except JobRejected as exc: except JobRejected as exc:
await self.host.evolve( await self.repository.apply(
f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc)) f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
) )
except Exception as exc: except Exception as exc:
latest = await self.host.get(state.id) latest = await self.repository.get_job(job.id)
stage = latest.stage if latest else running.stage stage = latest.stage if latest else running.stage
await self.host.evolve( await self.repository.apply(
f"task:{task.id}:failed", f"task:{task.id}:failed",
JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage), JobFailed(job_id=job.id, error=_safe_error(exc), stage=stage),
) )
else: else:
await self.host.evolve( await self.repository.apply(
f"task:{task.id}:completed", f"task:{task.id}:completed",
JobCompleted( JobCompleted(
job_id=state.id, job_id=job.id,
comment_body=reporter.final_body or "Agent job completed.", comment_body=body or "Agent job completed.",
), ),
) )
finally:
reset_reporter(token)
async def _reconcile(self, task: ListenerTask, state: JobState) -> None: async def _reconcile(self, task: Task, job: Job) -> None:
latest = await self.host.get(state.id) latest = await self.repository.get_job(job.id)
if latest is None: if latest is None:
return return
body = render_job_comment(latest) body = render_job_comment(latest)
@@ -178,7 +177,7 @@ class Worker:
comment_id = await self.gitea.create_comment( comment_id = await self.gitea.create_comment(
latest.repo_owner, latest.repo_name, latest.issue_number, body latest.repo_owner, latest.repo_name, latest.issue_number, body
) )
await self.host.evolve( await self.repository.apply(
f"task:{task.id}:comment:{comment_id}", f"task:{task.id}:comment:{comment_id}",
CommentLinked(job_id=latest.id, comment_id=comment_id), CommentLinked(job_id=latest.id, comment_id=comment_id),
) )
@@ -187,25 +186,27 @@ class Worker:
) )
async def _recover(self) -> None: async def _recover(self) -> None:
await self.storage.recover_tasks() await self.repository.recover_tasks()
for state in await self.storage.running_job_states(): for job in await self.repository.running_jobs():
await self.host.evolve( await self.repository.apply(
f"recovery:{state.id}:service-restarted", f"recovery:{job.id}:service-restarted",
ServiceRestarted(job_id=state.id), ServiceRestarted(job_id=job.id),
) )
async def _abort_job_sessions(self, state: JobState) -> None: async def _abort_job_sessions(self, job: Job) -> None:
sessions: set[tuple[str, Path]] = set() sessions: set[tuple[str, Path]] = set()
workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None workflow = (
await self.repository.get_workflow(job.workflow_id) if job.workflow_id else None
)
if workflow: if workflow:
sessions.update( sessions.update(
(session, workflow.workspace_path) (session, workflow.workspace_path)
for session in (workflow.primary_session_id, workflow.reviewer_session_id) for session in (workflow.primary_session_id, workflow.reviewer_session_id)
if session if session
) )
elif state.runtime_session_id: elif job.runtime_session_id:
sessions.add( sessions.add(
(state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo") (job.runtime_session_id, self.workspaces_dir / f"fix-{job.id}" / "repo")
) )
for session, workspace in sessions: for session, workspace in sessions:
await self.opencode.abort(session, workspace) await self.opencode.abort(session, workspace)
+1 -1
View File
@@ -1 +1 @@
"""OpenCode workflow orchestration.""" """Functional workflow orchestration."""
-57
View File
@@ -1,57 +0,0 @@
from __future__ import annotations
from pathlib import Path
from agentci.domain.models import AgentResult, Job
from agentci.reporting import reporter
from agentci.workflows.common import Dependencies, JobRejected
class ChangeSet:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
async def commit_and_push(
self,
job: Job,
workspace: Path,
branch: str,
result: AgentResult,
*,
set_upstream: bool,
commit_prefix: str,
) -> str:
await reporter().progress("validating changes")
if not await self.deps.git.has_changes(workspace):
raise JobRejected("OpenCode completed without producing any file changes.")
await self.deps.git.diff_check(workspace)
title = _commit_title(result.summary_markdown)
await reporter().progress("committing changes")
sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}")
await reporter().progress("pushing changes")
await self.deps.git.push(workspace, branch, set_upstream=set_upstream)
return sha
def pull_request_body(issue_number: int, result: AgentResult) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
return (
f"Closes #{issue_number}\n\n"
f"## Implementation\n\n{result.summary_markdown}\n\n"
f"## Validation\n\n{tests}\n\n"
"_Created by Agent CI._"
)
def result_comment(result: AgentResult, *, sha: str | None = None) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
commit = f"\n\nCommit: `{sha}`" if sha else ""
return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}"
def _commit_title(markdown: str) -> str:
for line in markdown.splitlines():
value = line.strip().lstrip("#").strip()
if value:
return value[:72]
return "apply requested changes"
-101
View File
@@ -1,101 +0,0 @@
from __future__ import annotations
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
from agentci.reporting import reporter
from agentci.workflows.common import (
Dependencies,
report_for_prompt,
report_json,
required_session,
)
class CodeReviewLoop:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
async def run(
self,
job: Job,
workflow: Workflow,
issue_context: str,
plan: str,
result: AgentResult,
) -> tuple[AgentResult, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.implement_review_rounds):
await reporter().progress(
f"reviewing implementation {round_index + 1}/"
f"{self.deps.settings.implement_review_rounds}"
)
report = await self.once(
workflow,
issue_context=issue_context,
plan=plan,
pull_context=(
"The proposed pull request is the current uncommitted working-tree diff. "
"Review only that diff."
),
)
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == self.deps.settings.implement_review_rounds - 1:
break
prompt = self.deps.prompts.render(
"implementation_revision",
review=report_for_prompt(workflow.review_json),
development_environment=self.deps.development.description,
)
result = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
return result, report
async def once(
self,
workflow: Workflow,
*,
issue_context: str,
plan: str,
pull_context: str,
) -> ReviewReport:
prompt = self.deps.prompts.render(
"implementation_review",
issue_context=issue_context,
artifact=plan,
pull_context=pull_context,
)
if workflow.reviewer_session_id:
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id = await self.deps.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return report
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.domain.models import ReviewReport
from agentci.prompts import PromptLibrary
from agentci.reporting import reporter
from agentci.workflows.context import ContextBuilder
class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
@dataclass(frozen=True)
class Dependencies:
settings: Settings
storage: Storage
gitea: GiteaClient
git: GitClient
opencode: OpenCodeClient
prompts: PromptLibrary
context: ContextBuilder
development: DevelopmentEnvironment
def review_markdown(report: ReviewReport) -> str:
if not report.findings:
return ""
lines = ["## Remaining review findings", "", report.summary]
for finding in report.findings:
location = f" — `{finding.location}`" if finding.location else ""
lines.extend(
[
"",
f"### {finding.severity.value.upper()}: {finding.title}{location}",
finding.detail,
"",
f"Recommendation: {finding.recommendation}",
]
)
return "\n".join(lines)
def report_json(report: ReviewReport) -> str:
return report.model_dump_json()
def report_for_prompt(report_json_value: str | None) -> str:
if not report_json_value:
return "(none)"
try:
return json.dumps(json.loads(report_json_value), indent=2)
except json.JSONDecodeError:
return report_json_value
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
async def finish_job(body: str) -> None:
reporter().finish(body)
+90 -68
View File
@@ -1,81 +1,103 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any from typing import Any
from agentci.adapters.gitea import GiteaClient from agentci.engine.repository import Repository
from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo from agentci.gitea import CommentInfo, Gitea, PullRequestInfo
from agentci.adapters.storage import Storage
class ContextBuilder: async def build_issue_context(
def __init__(self, gitea: GiteaClient, storage: Storage) -> None: gitea: Gitea,
self.gitea = gitea repository: Repository,
self.storage = storage owner: str,
repo: str,
number: int,
) -> str:
issue, comments, operational = await asyncio.gather(
gitea.issue(owner, repo, number),
gitea.issue_comments(owner, repo, number),
repository.operational_comment_ids(owner, repo, number),
)
discussion = "\n\n".join(
_format_comment(comment) for comment in comments if comment.id not in operational
)
return (
f"Repository: {owner}/{repo}\n"
f"Issue: #{number} \u2014 {issue.title}\n"
f"State: {issue.state}\n\n"
f"## Issue body\n{issue.body or '(empty)'}\n\n"
f"## Discussion\n{discussion or '(none)'}"
)
async def issue_context(self, owner: str, repo: str, number: int) -> str:
issue = await self.gitea.issue(owner, repo, number) async def build_pull_request_context(
comments = await self.gitea.issue_comments(owner, repo, number) gitea: Gitea,
operational = await self.storage.operational_comment_ids(owner, repo, number) owner: str,
discussion = "\n\n".join( repo: str,
_format_comment(comment) for comment in comments if comment.id not in operational number: int,
) -> tuple[PullRequestInfo, str]:
pull, timeline, reviews, commits = await asyncio.gather(
gitea.pull_request(owner, repo, number),
gitea.issue_comments(owner, repo, number),
gitea.pull_reviews(owner, repo, number),
gitea.pull_commits(owner, repo, number),
)
review_text = await _format_reviews(gitea, owner, repo, number, reviews)
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
commit_text = "\n".join(
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
for item in commits
)
context = (
f"Repository: {owner}/{repo}\n"
f"Pull request: #{number} \u2014 {pull.title}\n"
f"State: {pull.state}; merged: {pull.merged}\n"
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
f"{pull.head_branch} @ {pull.head_sha}\n\n"
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
f"## Commits\n{commit_text or '(none)'}\n\n"
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
f"## Formal and inline reviews\n{review_text or '(none)'}"
)
return pull, context
async def _format_reviews(
gitea: Gitea,
owner: str,
repo: str,
number: int,
reviews: list[dict[str, Any]],
) -> str:
details = [
(
int(review["id"]),
review.get("user", {}).get("login", "unknown"),
review.get("state", "unknown"),
review.get("body") or "(empty)",
) )
return ( for review in reviews
f"Repository: {owner}/{repo}\n" ]
f"Issue: #{number}{issue.title}\n" comment_groups = await asyncio.gather(
f"State: {issue.state}\n\n" *(
f"## Issue body\n{issue.body or '(empty)'}\n\n" gitea.review_comments(owner, repo, number, review_id)
f"## Discussion\n{discussion or '(none)'}" for review_id, _, _, _ in details
) )
)
async def pull_request_context( sections: list[str] = []
self, owner: str, repo: str, number: int for (review_id, author, state, body), comments in zip(
) -> tuple[PullRequestInfo, str]: details, comment_groups, strict=True
pull = await self.gitea.pull_request(owner, repo, number) ):
timeline = await self.gitea.issue_comments(owner, repo, number) lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
reviews = await self.gitea.pull_reviews(owner, repo, number) for comment in comments:
commits = await self.gitea.pull_commits(owner, repo, number) path = comment.get("path") or "unknown file"
review_text = await self._format_reviews(owner, repo, number, reviews) line = comment.get("new_position") or comment.get("old_position") or "?"
timeline_text = "\n\n".join(_format_comment(item) for item in timeline) text = comment.get("body") or ""
commit_text = "\n".join( lines.append(f"- `{path}:{line}`: {text}")
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}" sections.append("\n".join(lines))
for item in commits return "\n\n".join(sections)
)
context = (
f"Repository: {owner}/{repo}\n"
f"Pull request: #{number}{pull.title}\n"
f"State: {pull.state}; merged: {pull.merged}\n"
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
f"{pull.head_branch} @ {pull.head_sha}\n\n"
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
f"## Commits\n{commit_text or '(none)'}\n\n"
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
f"## Formal and inline reviews\n{review_text or '(none)'}"
)
return pull, context
async def _format_reviews(
self,
owner: str,
repo: str,
number: int,
reviews: list[dict[str, Any]],
) -> str:
sections: list[str] = []
for review in reviews:
review_id = int(review["id"])
author = review.get("user", {}).get("login", "unknown")
state = review.get("state", "unknown")
body = review.get("body") or "(empty)"
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
for comment in await self.gitea.review_comments(owner, repo, number, review_id):
path = comment.get("path") or "unknown file"
line = comment.get("new_position") or comment.get("old_position") or "?"
text = comment.get("body") or ""
lines.append(f"- `{path}:{line}`: {text}")
sections.append("\n".join(lines))
return "\n\n".join(sections)
def _format_comment(comment: CommentInfo) -> str: def _format_comment(comment: CommentInfo) -> str:
return f"### {comment.author} at {comment.created_at}\n{comment.body}" return f"### {comment.author} at {comment.created_at}\n{comment.body}"
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import logging
from agentci.engine.model import Job, JobKind
from agentci.engine.run import JobRun
from agentci.workflows.implementation import implement
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__)
async def dispatch(job: Job, run: JobRun, services: WorkflowServices) -> str:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
"target": job.target_key,
"stage": job.kind.value,
}
log.info("workflow dispatch started", extra=extra)
try:
match job.kind:
case JobKind.PLAN:
body = await create_plan(job, run, services)
case JobKind.DISCUSS:
body = await discuss_plan(job, run, services)
case JobKind.ITERATE_PLAN:
body = await iterate_plan(job, run, services)
case JobKind.IMPLEMENT:
body = await implement(job, run, services)
case JobKind.ITERATE_IMPLEMENT:
body = await iterate_implementation(job, run, services)
case JobKind.FIX:
body = await fix_pull_request(job, run, services)
case _:
raise KeyError(job.kind)
except Exception:
log.exception("workflow dispatch failed", extra=extra)
raise
log.info("workflow dispatch completed", extra=extra)
return body
-44
View File
@@ -1,44 +0,0 @@
from __future__ import annotations
import logging
from typing import Any
from agentci.domain.models import JobKind
from agentci.domain.state_machine import JobState
from agentci.workflows.common import Dependencies
from agentci.workflows.implement import ImplementWorkflow
from agentci.workflows.plan import PlanWorkflow
from agentci.workflows.pull_request import PullRequestWorkflow
log = logging.getLogger(__name__)
class Dispatcher:
def __init__(self, dependencies: Dependencies) -> None:
plan = PlanWorkflow(dependencies)
pull_request = PullRequestWorkflow(dependencies)
self.handlers: dict[JobKind, Any] = {
JobKind.PLAN: plan.plan,
JobKind.DISCUSS: plan.discuss,
JobKind.ITERATE_PLAN: plan.iterate,
JobKind.IMPLEMENT: ImplementWorkflow(dependencies).run,
JobKind.ITERATE_IMPLEMENT: pull_request.iterate,
JobKind.FIX: pull_request.fix,
}
async def dispatch(self, job: JobState) -> None:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
"target": job.target_key,
"stage": job.kind.value,
}
log.info("workflow dispatch started", extra=extra)
try:
await self.handlers[job.kind](job)
except Exception:
log.exception("workflow dispatch failed", extra=extra)
raise
log.info("workflow dispatch completed", extra=extra)
-148
View File
@@ -1,148 +0,0 @@
from __future__ import annotations
from uuid import uuid4
from agentci.domain.models import (
AgentResult,
Job,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment
from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import (
Dependencies,
JobRejected,
agent_comment,
finish_job,
report_json,
review_markdown,
)
class ImplementWorkflow:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
self.review = CodeReviewLoop(dependencies)
self.changes = ChangeSet(dependencies)
async def run(self, job: Job) -> None:
await self._reject_duplicate(job)
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
issue = await self.deps.gitea.issue(job.repo_owner, job.repo_name, job.issue_number)
workflow_id = str(uuid4())
branch = (
f"{self.deps.settings.branch_prefix}/issue-{job.issue_number}-"
f"{workflow_id[:8]}"
)
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
await reporter().progress("cloning")
base_sha = await self.deps.git.clone(
job.repo_owner,
job.repo_name,
repository.default_branch,
workspace,
)
await self.deps.git.create_branch(workspace, branch)
workflow = Workflow(
id=workflow_id,
kind=WorkflowKind.IMPLEMENT,
repo_owner=job.repo_owner,
repo_name=job.repo_name,
issue_number=job.issue_number,
workspace_path=workspace,
base_sha=base_sha,
branch=branch,
)
await reporter().create_workflow(workflow, "installing development environment")
await self.deps.development.prepare(workspace)
await reporter().progress("implementing")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
plan = await self.deps.storage.latest_workflow(
job.repo_owner, job.repo_name, job.issue_number, WorkflowKind.PLAN
)
prompt = self.deps.prompts.render(
"implement_initial",
context=context,
artifact=plan.artifact if plan and plan.artifact else "(no canonical plan)",
request=job.message or "(no additional request)",
development_environment=self.deps.development.description,
)
session_id = await self.deps.opencode.create_session(workspace, "implementation")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await reporter().link_runtime_session(session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow.artifact = result.model_dump_json()
await self.deps.storage.update_workflow(workflow)
result, report = await self.review.run(
job,
workflow,
context,
plan.artifact if plan and plan.artifact else "(no canonical plan)",
result,
)
sha = await self.changes.commit_and_push(
job,
workspace,
branch,
result,
set_upstream=True,
commit_prefix="agent",
)
await reporter().progress("creating pull request")
pull = await self.deps.gitea.create_pull_request(
job.repo_owner,
job.repo_name,
title=f"Agent: {issue.title}",
body=pull_request_body(job.issue_number, result),
head=branch,
base=repository.default_branch,
)
workflow.pr_number = pull.number
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow)
pull_url = (
f"{self.deps.settings.gitea_url}/{job.repo_owner}/"
f"{job.repo_name}/pulls/{pull.number}"
)
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
body = agent_comment("implementation", workflow.id, body)
remaining = review_markdown(report)
if remaining:
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def _reject_duplicate(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await self.deps.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open:
raise JobRejected(
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
"on that pull request."
)
if pull.merged:
raise JobRejected(
f"Agent PR #{pull.number} has already been merged for this issue."
)
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from uuid import uuid4
from agentci.engine.model import Job, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.workflows.context import build_issue_context
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected,
commit_title,
final_comment,
pull_request_body,
result_comment,
)
from agentci.workflows.review import review_implementation_loop
from agentci.workflows.services import WorkflowServices
async def implement(job: Job, run: JobRun, services: WorkflowServices) -> str:
await _reject_duplicate(job, services)
default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
issue = await services.gitea.issue(job.repo_owner, job.repo_name, job.issue_number)
workflow_id = str(uuid4())
branch = (
f"{services.settings.branch_prefix}/issue-{job.issue_number}-"
f"{workflow_id[:8]}"
)
workspace = services.settings.workspaces_dir / workflow_id / "repo"
await run.stage("cloning")
base_sha = await services.git.clone(
job.repo_owner,
job.repo_name,
default_branch,
workspace,
)
await services.git.create_branch(workspace, branch)
workflow = Workflow(
id=workflow_id,
kind=WorkflowKind.IMPLEMENT,
repo_owner=job.repo_owner,
repo_name=job.repo_name,
issue_number=job.issue_number,
workspace_path=workspace,
base_sha=base_sha,
branch=branch,
)
await run.create_workflow(workflow, "installing development environment")
await services.development.prepare(workspace)
await run.stage("implementing")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
plan = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
job.issue_number,
WorkflowKind.PLAN,
)
plan_artifact = plan.artifact if plan and plan.artifact else "(no canonical plan)"
prompt = services.prompts.render(
"implement_initial",
context=context,
artifact=plan_artifact,
request=job.message or "(no additional request)",
development_environment=services.development.description,
)
session_id = await services.opencode.create_session(workspace, "implementation")
workflow = replace(workflow, primary_session_id=session_id)
await services.repository.save_workflow(workflow)
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow = replace(workflow, artifact=result.model_dump_json())
await services.repository.save_workflow(workflow)
workflow, result, report = await review_implementation_loop(
workflow,
context,
plan_artifact,
result,
run,
services,
)
sha = await commit_and_push(
run,
services,
workspace,
branch,
result,
set_upstream=True,
commit_prefix="agent",
)
await run.stage("creating pull request")
pull = await services.gitea.create_pull_request(
job.repo_owner,
job.repo_name,
title=f"Agent: {issue.title}",
body=pull_request_body(job.issue_number, result),
head=branch,
base=default_branch,
)
workflow = replace(
workflow,
pr_number=pull.number,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
pull_url = (
f"{services.settings.gitea_url}/{job.repo_owner}/"
f"{job.repo_name}/pulls/{pull.number}"
)
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
return final_comment("implementation", workflow.id, body, report)
async def commit_and_push(
run: JobRun,
services: WorkflowServices,
workspace: Path,
branch: str,
result: AgentResult,
*,
set_upstream: bool,
commit_prefix: str,
) -> str:
await run.stage("validating changes")
if not await services.git.has_changes(workspace):
raise JobRejected("OpenCode completed without producing any file changes.")
await services.git.diff_check(workspace)
title = commit_title(result.summary_markdown)
await run.stage("committing changes")
sha = await services.git.commit(workspace, f"{commit_prefix}: {title}")
await run.stage("pushing changes")
await services.git.push(workspace, branch, set_upstream=set_upstream)
return sha
async def _reject_duplicate(job: Job, services: WorkflowServices) -> None:
workflows = await services.repository.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await services.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open:
raise JobRejected(
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
"on that pull request."
)
if pull.merged:
raise JobRejected(
f"Agent PR #{pull.number} has already been merged for this issue."
)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, Field
class ReviewSeverity(StrEnum):
BLOCKING = "blocking"
MAJOR = "major"
MINOR = "minor"
class PlanArtifact(BaseModel):
plan_markdown: str = Field(min_length=1)
class DiscussionReply(BaseModel):
markdown: str = Field(min_length=1)
class AgentResult(BaseModel):
summary_markdown: str = Field(min_length=1)
tests: list[str] = Field(default_factory=list)
class ReviewFinding(BaseModel):
severity: ReviewSeverity
title: str
detail: str
location: str | None = None
recommendation: str
class ReviewReport(BaseModel):
summary: str
findings: list[ReviewFinding] = Field(default_factory=list)
@property
def has_serious_findings(self) -> bool:
return any(
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
for finding in self.findings
)
+170 -216
View File
@@ -1,235 +1,189 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from uuid import uuid4 from uuid import uuid4
from agentci.domain.models import ( from agentci.engine.model import (
DiscussionReply,
Job, Job,
PlanArtifact,
ReviewReport,
Workflow, Workflow,
WorkflowKind, WorkflowKind,
WorkflowStatus, WorkflowStatus,
) )
from agentci.reporting import reporter from agentci.engine.run import JobRun
from agentci.workflows.common import ( from agentci.workflows.context import build_issue_context
Dependencies, from agentci.workflows.model import DiscussionReply, PlanArtifact
from agentci.workflows.render import (
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job, final_comment,
report_for_prompt, report_for_prompt,
report_json,
required_session,
review_markdown,
) )
from agentci.workflows.review import review_plan_loop, review_plan_once
from agentci.workflows.services import WorkflowServices
class PlanWorkflow: async def create_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
def __init__(self, dependencies: Dependencies) -> None: default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
self.deps = dependencies workflow_id = str(uuid4())
workspace = services.settings.workspaces_dir / workflow_id / "repo"
async def plan(self, job: Job) -> None: await run.stage("cloning")
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name) base_sha = await services.git.clone(
workflow_id = str(uuid4()) job.repo_owner,
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo" job.repo_name,
await reporter().progress("cloning") default_branch,
base_sha = await self.deps.git.clone( workspace,
job.repo_owner, )
job.repo_name, workflow = Workflow(
repository.default_branch, id=workflow_id,
workspace, kind=WorkflowKind.PLAN,
) repo_owner=job.repo_owner,
workflow = Workflow( repo_name=job.repo_name,
id=workflow_id, issue_number=job.issue_number,
kind=WorkflowKind.PLAN, workspace_path=workspace,
repo_owner=job.repo_owner, base_sha=base_sha,
repo_name=job.repo_name, )
issue_number=job.issue_number, await run.create_workflow(workflow, "planning")
workspace_path=workspace,
base_sha=base_sha,
)
await reporter().create_workflow(workflow, "planning")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
prompt = self.deps.prompts.render(
"plan_initial",
context=context,
request=job.message or "(no additional request)",
)
session_id = await self.deps.opencode.create_session(workspace, "plan")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await reporter().link_runtime_session(session_id)
artifact = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow.artifact = artifact.plan_markdown
await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
async def discuss(self, job: Job) -> None: context = await build_issue_context(
workflow = await self._latest_plan(job) services.gitea,
if workflow.runtime != "opencode": services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
prompt = services.prompts.render(
"plan_initial",
context=context,
request=job.message or "(no additional request)",
)
session_id = await services.opencode.create_session(workspace, "plan")
workflow = replace(workflow, primary_session_id=session_id)
await services.repository.save_workflow(workflow)
await run.link_session(session_id)
artifact = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow = replace(workflow, artifact=artifact.plan_markdown)
await services.repository.save_workflow(workflow)
workflow, artifact, report = await review_plan_loop(
workflow, context, artifact, run, services
)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
async def discuss_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
workflow = await _latest_plan(job, services)
if workflow.runtime != "opencode":
raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; "
"start a new `/agent plan`."
)
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await run.link_workflow(workflow.id, "discussing")
prompt = services.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message or ""
)
reply = await services.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
return agent_comment("discussion", workflow.id, reply.markdown)
async def iterate_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
await _reject_if_active_or_merged_pr(job, services)
workflow = await _latest_plan(job, services)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
raise JobRejected("The latest plan has no saved artifact.")
await run.link_workflow(workflow.id, "iterating plan")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
prompt = services.prompts.render(
"plan_iterate",
context=context,
artifact=workflow.artifact,
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await services.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow, report = await review_plan_once(workflow, context, artifact, services)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow:
workflow = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
job.issue_number,
WorkflowKind.PLAN,
)
if workflow is None:
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
return workflow
async def _reject_if_active_or_merged_pr(
job: Job, services: WorkflowServices
) -> None:
workflows = await services.repository.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await services.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open or pull.merged:
raise JobRejected( raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; " f"Issue plan iteration is disabled because agent PR #{pull.number} "
"start a new `/agent plan`." "is open or merged. Iterate an open implementation on its PR."
) )
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await reporter().link_workflow(workflow.id, "discussing")
prompt = self.deps.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message
)
reply = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
await finish_job(agent_comment("discussion", workflow.id, reply.markdown))
async def iterate(self, job: Job) -> None:
await self._reject_if_active_or_merged_pr(job)
workflow = await self._latest_plan(job)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
raise JobRejected("The latest plan has no saved artifact.")
await reporter().link_workflow(workflow.id, "iterating plan")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
prompt = self.deps.prompts.render(
"plan_iterate",
context=context,
artifact=workflow.artifact,
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
report = await self._review(workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
async def _review_loop(
self, job: Job, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.plan_review_rounds):
await reporter().progress(
f"reviewing plan {round_index + 1}/{self.deps.settings.plan_review_rounds}"
)
report = await self._review(workflow, context, artifact)
workflow.artifact = artifact.plan_markdown
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == self.deps.settings.plan_review_rounds - 1:
break
prompt = self.deps.prompts.render(
"plan_revision",
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
return report
async def _review(
self, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
prompt = self.deps.prompts.render(
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review")
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return report
async def _finish(
self,
job: Job,
workflow: Workflow,
artifact: PlanArtifact,
report: ReviewReport,
) -> None:
workflow.artifact = artifact.plan_markdown
workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow)
body = agent_comment("plan", workflow.id, artifact.plan_markdown)
remaining = review_markdown(report)
if remaining:
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def _latest_plan(self, job: Job) -> Workflow:
workflow = await self.deps.storage.latest_workflow(
job.repo_owner, job.repo_name, job.issue_number, WorkflowKind.PLAN
)
if workflow is None:
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
return workflow
async def _reject_if_active_or_merged_pr(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await self.deps.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open or pull.merged:
raise JobRejected(
f"Issue plan iteration is disabled because agent PR #{pull.number} "
"is open or merged. Iterate an open implementation on its PR."
)
+147 -129
View File
@@ -1,142 +1,160 @@
from __future__ import annotations from __future__ import annotations
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus from dataclasses import replace
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, result_comment from agentci.engine.model import Job, WorkflowKind, WorkflowStatus
from agentci.workflows.code_review import CodeReviewLoop from agentci.engine.run import JobRun
from agentci.workflows.common import ( from agentci.workflows.context import (
Dependencies, build_issue_context,
build_pull_request_context,
)
from agentci.workflows.implementation import commit_and_push
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job, final_comment,
report_for_prompt, report_for_prompt,
report_json, result_comment,
review_markdown,
) )
from agentci.workflows.review import review_implementation_once
from agentci.workflows.services import WorkflowServices
class PullRequestWorkflow: async def iterate_implementation(
def __init__(self, dependencies: Dependencies) -> None: job: Job, run: JobRun, services: WorkflowServices
self.deps = dependencies ) -> str:
self.review = CodeReviewLoop(dependencies) pull_number = _pull_number(job)
self.changes = ChangeSet(dependencies) workflow = await services.repository.workflow_for_pr(
job.repo_owner, job.repo_name, pull_number
)
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED:
raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
async def iterate(self, job: Job) -> None: pull, context = await build_pull_request_context(
pull_number = _pull_number(job) services.gitea, job.repo_owner, job.repo_name, pull_number
workflow = await self.deps.storage.workflow_for_pr( )
job.repo_owner, job.repo_name, pull_number if not pull.is_open:
) raise JobRejected("Implementation iteration requires an open pull request.")
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED: if workflow.branch != pull.head_branch:
raise JobRejected( raise JobRejected("The pull request head branch no longer matches its workflow.")
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
pull, context = await self.deps.context.pull_request_context(
job.repo_owner, job.repo_name, pull_number
)
if not pull.is_open:
raise JobRejected("Implementation iteration requires an open pull request.")
if workflow.branch != pull.head_branch:
raise JobRejected("The pull request head branch no longer matches its workflow.")
await reporter().link_workflow(workflow.id, "synchronizing branch")
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch)
await reporter().progress("installing development environment")
await self.deps.development.prepare(workflow.workspace_path)
await reporter().progress("implementing iteration")
prompt = self.deps.prompts.render(
"implementation_iterate",
context=context,
review=report_for_prompt(workflow.review_json),
message=job.message or "(perform one additional reviewed refinement)",
development_environment=self.deps.development.description,
)
result = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
issue_context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, workflow.issue_number
)
plan = await self.deps.storage.latest_workflow(
job.repo_owner, job.repo_name, workflow.issue_number, WorkflowKind.PLAN
)
report = await self.review.once(
workflow,
issue_context=issue_context,
plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
pull_context=context,
)
sha = await self.changes.commit_and_push(
job,
workflow.workspace_path,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent iterate",
)
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
body = agent_comment(
"iteration", workflow.id, result_comment(result, sha=sha)
)
remaining = review_markdown(report)
if remaining:
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def fix(self, job: Job) -> None: await run.link_workflow(workflow.id, "synchronizing branch")
pull_number = _pull_number(job) await services.git.sync_branch(workflow.workspace_path, pull.head_branch)
pull, context = await self.deps.context.pull_request_context( await run.stage("installing development environment")
job.repo_owner, job.repo_name, pull_number await services.development.prepare(workflow.workspace_path)
) await run.stage("implementing iteration")
if not pull.is_open: prompt = services.prompts.render(
raise JobRejected("Fixes require an open pull request.") "implementation_iterate",
workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo" context=context,
await reporter().progress("cloning pull request") review=report_for_prompt(workflow.review_json),
await self.deps.git.clone( message=job.message or "(perform one additional reviewed refinement)",
pull.head_owner, development_environment=services.development.description,
pull.head_repo, )
pull.head_branch, result = await services.opencode.resume(
workspace, session_id=workflow.primary_session_id,
) prompt=prompt,
await reporter().progress("installing development environment") model=services.settings.implement_model,
await self.deps.development.prepare(workspace) variant=services.settings.implement_variant,
prompt = self.deps.prompts.render( workspace=workflow.workspace_path,
"fix", schema_name="agent_result.json",
context=context, result_type=AgentResult,
message=job.message or "(address the pull request feedback)", )
development_environment=self.deps.development.description,
) issue_context = await build_issue_context(
await reporter().progress("fixing") services.gitea,
session_id = await self.deps.opencode.create_session(workspace, "fix") services.repository,
await reporter().link_runtime_session(session_id) job.repo_owner,
result = await self.deps.opencode.resume( job.repo_name,
session_id=session_id, workflow.issue_number,
workspace=workspace, )
prompt=prompt, plan = await services.repository.latest_workflow(
model=self.deps.settings.implement_model, job.repo_owner,
variant=self.deps.settings.implement_variant, job.repo_name,
schema_name="agent_result.json", workflow.issue_number,
result_type=AgentResult, WorkflowKind.PLAN,
) )
sha = await self.changes.commit_and_push( workflow, report = await review_implementation_once(
job, workflow,
workspace, issue_context=issue_context,
pull.head_branch, plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
result, pull_context=context,
set_upstream=False, services=services,
commit_prefix="agent fix", )
) sha = await commit_and_push(
await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha))) run,
services,
workflow.workspace_path,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent iterate",
)
workflow = replace(
workflow,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
return final_comment(
"iteration", workflow.id, result_comment(result, sha=sha), report
)
async def fix_pull_request(
job: Job, run: JobRun, services: WorkflowServices
) -> str:
pull_number = _pull_number(job)
pull, context = await build_pull_request_context(
services.gitea, job.repo_owner, job.repo_name, pull_number
)
if not pull.is_open:
raise JobRejected("Fixes require an open pull request.")
workspace = services.settings.workspaces_dir / f"fix-{job.id}" / "repo"
await run.stage("cloning pull request")
await services.git.clone(
pull.head_owner,
pull.head_repo,
pull.head_branch,
workspace,
)
await run.stage("installing development environment")
await services.development.prepare(workspace)
prompt = services.prompts.render(
"fix",
context=context,
message=job.message or "(address the pull request feedback)",
development_environment=services.development.description,
)
await run.stage("fixing")
session_id = await services.opencode.create_session(workspace, "fix")
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
sha = await commit_and_push(
run,
services,
workspace,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent fix",
)
return agent_comment("fix", job.id, result_comment(result, sha=sha))
def _pull_number(job: Job) -> int: def _pull_number(job: Job) -> int:
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import json
from agentci.workflows.model import AgentResult, ReviewReport
class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
def review_markdown(report: ReviewReport) -> str:
if not report.findings:
return ""
lines = ["## Remaining review findings", "", report.summary]
for finding in report.findings:
location = f" \u2014 `{finding.location}`" if finding.location else ""
lines.extend(
[
"",
f"### {finding.severity.value.upper()}: {finding.title}{location}",
finding.detail,
"",
f"Recommendation: {finding.recommendation}",
]
)
return "\n".join(lines)
def report_for_prompt(stored_review: str | None) -> str:
if not stored_review:
return "(none)"
try:
return json.dumps(json.loads(stored_review), indent=2)
except json.JSONDecodeError:
return stored_review
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
def final_comment(
kind: str,
workflow_id: str,
body: str,
report: ReviewReport | None = None,
) -> str:
rendered = agent_comment(kind, workflow_id, body)
remaining = review_markdown(report) if report else ""
return f"{rendered}\n\n{remaining}" if remaining else rendered
def pull_request_body(issue_number: int, result: AgentResult) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
return (
f"Closes #{issue_number}\n\n"
f"## Implementation\n\n{result.summary_markdown}\n\n"
f"## Validation\n\n{tests}\n\n"
"_Created by Agent CI._"
)
def result_comment(result: AgentResult, *, sha: str | None = None) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
commit = f"\n\nCommit: `{sha}`" if sha else ""
return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}"
def commit_title(markdown: str) -> str:
for line in markdown.splitlines():
value = line.strip().lstrip("#").strip()
if value:
return value[:72]
return "apply requested changes"
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from dataclasses import replace
from agentci.engine.model import Workflow
from agentci.engine.run import JobRun
from agentci.workflows.model import AgentResult, PlanArtifact, ReviewReport
from agentci.workflows.render import report_for_prompt, required_session
from agentci.workflows.services import WorkflowServices
async def review_plan_loop(
workflow: Workflow,
context: str,
artifact: PlanArtifact,
run: JobRun,
services: WorkflowServices,
) -> tuple[Workflow, PlanArtifact, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(services.settings.plan_review_rounds):
await run.stage(
f"reviewing plan {round_index + 1}/{services.settings.plan_review_rounds}"
)
workflow, report = await review_plan_once(
workflow, context, artifact, services
)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == services.settings.plan_review_rounds - 1:
break
prompt = services.prompts.render(
"plan_revision",
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await services.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow = replace(workflow, artifact=artifact.plan_markdown)
await services.repository.save_workflow(workflow)
return workflow, artifact, report
async def review_plan_once(
workflow: Workflow,
context: str,
artifact: PlanArtifact,
services: WorkflowServices,
) -> tuple[Workflow, ReviewReport]:
prompt = services.prompts.render(
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
report = await services.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
session_id = await services.opencode.create_session(
workflow.workspace_path, "plan-review"
)
workflow = replace(workflow, reviewer_session_id=session_id)
await services.repository.save_workflow(workflow)
report = await services.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
async def review_implementation_loop(
workflow: Workflow,
issue_context: str,
plan: str,
result: AgentResult,
run: JobRun,
services: WorkflowServices,
) -> tuple[Workflow, AgentResult, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(services.settings.implement_review_rounds):
await run.stage(
f"reviewing implementation {round_index + 1}/"
f"{services.settings.implement_review_rounds}"
)
workflow, report = await review_implementation_once(
workflow,
issue_context=issue_context,
plan=plan,
pull_context=(
"The proposed pull request is the current uncommitted working-tree diff. "
"Review only that diff."
),
services=services,
)
workflow = replace(
workflow,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == services.settings.implement_review_rounds - 1:
break
prompt = services.prompts.render(
"implementation_revision",
review=report_for_prompt(workflow.review_json),
development_environment=services.development.description,
)
result = await services.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow = replace(workflow, artifact=result.model_dump_json())
await services.repository.save_workflow(workflow)
return workflow, result, report
async def review_implementation_once(
workflow: Workflow,
*,
issue_context: str,
plan: str,
pull_context: str,
services: WorkflowServices,
) -> tuple[Workflow, ReviewReport]:
prompt = services.prompts.render(
"implementation_review",
issue_context=issue_context,
artifact=plan,
pull_context=pull_context,
)
if workflow.reviewer_session_id:
report = await services.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
session_id = await services.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow = replace(workflow, reviewer_session_id=session_id)
await services.repository.save_workflow(workflow)
report = await services.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from agentci.config import Settings
from agentci.development import DevelopmentEnvironment
from agentci.engine.repository import Repository
from agentci.git import Git
from agentci.gitea import Gitea
from agentci.opencode import OpenCode
from agentci.prompts import PromptLibrary
@dataclass(frozen=True)
class WorkflowServices:
settings: Settings
repository: Repository
gitea: Gitea
git: Git
opencode: OpenCode
prompts: PromptLibrary
development: DevelopmentEnvironment
+129
View File
@@ -0,0 +1,129 @@
import asyncio
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
import agentci.app as app_module
class BlockingWorker:
def __init__(self, events: list[str]) -> None:
self.events = events
self.started = asyncio.Event()
async def run(self, stop: asyncio.Event) -> None:
self.events.append("worker-started")
self.started.set()
try:
await stop.wait()
except asyncio.CancelledError:
self.events.append(f"worker-cancelled:{stop.is_set()}")
raise
class FailingWorker:
def __init__(self) -> None:
self.started = asyncio.Event()
async def run(self, _stop: asyncio.Event) -> None:
self.started.set()
raise RuntimeError("worker failed")
class FakeRuntime:
def __init__(self, worker: object, events: list[str]) -> None:
self.worker = worker
self.events = events
self.closed = False
async def close(self) -> None:
self.events.append("runtime-closed")
self.closed = True
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
worker = BlockingWorker(events)
runtime = FakeRuntime(worker, events)
selected_settings = SimpleNamespace(name="selected")
built_with: list[object] = []
configured: list[bool] = []
async def build(settings: object) -> FakeRuntime:
built_with.append(settings)
return runtime
monkeypatch.setattr(app_module, "build_runtime", build)
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
async with application.router.lifespan_context(application):
await worker.started.wait()
assert application.state.runtime is runtime
assert not runtime.closed
assert built_with == [selected_settings]
assert configured == [True]
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
async def test_lifespan_closes_runtime_when_worker_task_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
worker = FailingWorker()
runtime = FakeRuntime(worker, events)
async def build(_settings: object) -> FakeRuntime:
return runtime
monkeypatch.setattr(app_module, "build_runtime", build)
monkeypatch.setattr(app_module, "configure_logging", lambda: None)
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="worker failed"):
async with application.router.lifespan_context(application):
await worker.started.wait()
assert runtime.closed
assert events == ["runtime-closed"]
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
configured: list[bool] = []
async def fail_build(_settings: object) -> None:
raise RuntimeError("database unavailable")
monkeypatch.setattr(app_module, "build_runtime", fail_build)
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="database unavailable"):
async with application.router.lifespan_context(application):
pytest.fail("startup failure must prevent serving requests")
assert configured == [True]
assert not hasattr(application.state, "runtime")
async def test_global_exception_handler_returns_safe_json() -> None:
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
@application.get("/explode")
async def explode() -> None:
raise RuntimeError("sensitive provider detail")
async with AsyncClient(
transport=ASGITransport(app=application, raise_app_exceptions=False),
base_url="http://test",
) as client:
response = await client.get("/explode")
assert response.status_code == 500
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
+348 -89
View File
@@ -1,22 +1,30 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
from agentci.domain.models import ( from pydantic import BaseModel
from agentci.engine.model import Workflow, WorkflowKind
from agentci.engine.run import JobRun
from agentci.workflows.model import (
AgentResult, AgentResult,
Job, PlanArtifact,
JobKind,
ReviewFinding, ReviewFinding,
ReviewReport, ReviewReport,
ReviewSeverity, ReviewSeverity,
Workflow,
WorkflowKind,
) )
from agentci.workflows.code_review import CodeReviewLoop from agentci.workflows.review import (
review_implementation_loop,
review_implementation_once,
review_plan_loop,
review_plan_once,
)
from agentci.workflows.services import WorkflowServices
def serious_report() -> ReviewReport: def serious_report(summary: str = "Needs work") -> ReviewReport:
return ReviewReport( return ReviewReport(
summary="Needs work", summary=summary,
findings=[ findings=[
ReviewFinding( ReviewFinding(
severity=ReviewSeverity.MAJOR, severity=ReviewSeverity.MAJOR,
@@ -28,101 +36,352 @@ def serious_report() -> ReviewReport:
) )
class FakeOpenCode: def clean_report() -> ReviewReport:
def __init__(self, reports: list[ReviewReport]) -> None: return ReviewReport(summary="Ready", findings=[])
self.reports = iter(reports)
self.reviews = 0
self.revisions = 0
async def create_session(self, *_args):
return "reviewer"
async def resume(self, **kwargs):
if kwargs["result_type"] is ReviewReport:
self.reviews += 1
return next(self.reports)
self.revisions += 1
return AgentResult(summary_markdown=f"revision {self.revisions}", tests=[])
class FakeStorage: def minor_report() -> ReviewReport:
async def update_job(self, *_args, **_kwargs): return ReviewReport(
return None summary="Optional improvement",
findings=[
async def update_workflow(self, *_args, **_kwargs): ReviewFinding(
return None severity=ReviewSeverity.MINOR,
title="Clarify wording",
detail="The wording could be clearer.",
class FakePrompts: recommendation="Tighten it when convenient.",
def render(self, name, **_kwargs): )
return name ],
def objects(rounds: int, reports: list[ReviewReport]):
opencode = FakeOpenCode(reports)
settings = SimpleNamespace(
implement_review_rounds=rounds,
implement_model="model",
implement_variant="high",
) )
deps = SimpleNamespace(
settings=settings,
opencode=opencode, class RecordingOpenCode:
storage=FakeStorage(), def __init__(self, responses: list[BaseModel]) -> None:
prompts=FakePrompts(), self.responses = list(responses)
development=SimpleNamespace(description="python"), self.created_sessions: list[tuple[Path, str]] = []
) self.resume_calls: list[dict[str, Any]] = []
workflow = Workflow(
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> BaseModel:
self.resume_calls.append(values)
response = self.responses.pop(0)
assert isinstance(response, values["result_type"])
return response
class RecordingRepository:
def __init__(self) -> None:
self.saved_workflows: list[Workflow] = []
async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow)
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
def workflow(*, reviewer_session_id: str | None = None) -> Workflow:
return Workflow(
id="flow", id="flow",
kind=WorkflowKind.IMPLEMENT, kind=WorkflowKind.IMPLEMENT,
repo_owner="org", repo_owner="org",
repo_name="repo", repo_name="repo",
issue_number=1, issue_number=1,
workspace_path=Path("."), workspace_path=Path("/workspace/repo"),
base_sha="abc", base_sha="abc",
primary_session_id="primary", primary_session_id="primary-session",
reviewer_session_id=reviewer_session_id,
) )
job = Job(
id="job",
kind=JobKind.IMPLEMENT,
target_key="target",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=None,
requester="alice",
message="",
comment_id=1,
)
return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type]
async def test_stops_after_clean_second_review() -> None: def objects(
clean = ReviewReport(summary="Ready", findings=[]) responses: list[BaseModel],
loop, opencode, workflow, job = objects(4, [serious_report(), clean]) *,
_, report = await loop.run( plan_rounds: int = 4,
job, implementation_rounds: int = 3,
workflow, ) -> tuple[
RecordingOpenCode,
RecordingRepository,
RecordingPrompts,
WorkflowServices,
]:
opencode = RecordingOpenCode(responses)
repository = RecordingRepository()
prompts = RecordingPrompts()
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
plan_review_rounds=plan_rounds,
plan_model="provider/plan",
plan_variant="high",
implement_review_rounds=implementation_rounds,
implement_model="provider/implement",
implement_variant="high",
),
opencode=opencode,
repository=repository,
prompts=prompts,
development=SimpleNamespace(description="Python 3.13"),
),
)
return opencode, repository, prompts, services
async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None:
revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"])
opencode, repository, prompts, services = objects(
[serious_report(), revised, clean_report()], implementation_rounds=4
)
run = RecordingRun()
original = workflow()
updated, result, report = await review_implementation_loop(
original,
"issue context", "issue context",
"canonical plan", "canonical plan",
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
run,
services,
) )
assert result == revised
assert report == clean_report()
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "implementation-review-session"
assert updated.artifact == revised.model_dump_json()
assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session")
assert run.stages == [
"reviewing implementation 1/4",
"reviewing implementation 2/4",
]
assert opencode.created_sessions == [(original.workspace_path, "implementation-review")]
assert [call["session_id"] for call in opencode.resume_calls] == [
"implementation-review-session",
"primary-session",
"implementation-review-session",
]
assert [call["result_type"] for call in opencode.resume_calls] == [
ReviewReport,
AgentResult,
ReviewReport,
]
assert [name for name, _ in prompts.calls] == [
"implementation_review",
"implementation_revision",
"implementation_review",
]
assert opencode.responses == []
async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None:
final_report = serious_report("Still failing after the last review")
opencode, repository, _, services = objects(
[
serious_report("round 1"),
AgentResult(summary_markdown="revision 1", tests=[]),
serious_report("round 2"),
AgentResult(summary_markdown="revision 2", tests=[]),
final_report,
],
implementation_rounds=3,
)
run = RecordingRun()
updated, result, report = await review_implementation_loop(
workflow(),
"issue context",
"canonical plan",
AgentResult(summary_markdown="initial", tests=[]),
run,
services,
)
assert result.summary_markdown == "revision 2"
assert report is final_report
assert updated.artifact == result.model_dump_json()
assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls].count(ReviewReport) == 3
assert [call["result_type"] for call in opencode.resume_calls].count(AgentResult) == 2
assert run.stages == [
"reviewing implementation 1/3",
"reviewing implementation 2/3",
"reviewing implementation 3/3",
]
assert opencode.responses == []
async def test_implementation_review_once_reuses_existing_reviewer_session() -> None:
opencode, repository, prompts, services = objects([clean_report()])
existing = workflow(reviewer_session_id="existing-reviewer")
updated, report = await review_implementation_once(
existing,
issue_context="issue context",
plan="canonical plan",
pull_context="pull request context",
services=services,
)
assert updated is existing
assert report == clean_report()
assert opencode.created_sessions == []
assert repository.saved_workflows == []
assert opencode.resume_calls == [
{
"session_id": "existing-reviewer",
"prompt": "rendered implementation_review",
"model": "provider/implement",
"variant": "high",
"workspace": existing.workspace_path,
"schema_name": "review.json",
"result_type": ReviewReport,
}
]
assert prompts.calls == [
(
"implementation_review",
{
"issue_context": "issue context",
"artifact": "canonical plan",
"pull_context": "pull request context",
},
)
]
async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None:
revised = PlanArtifact(plan_markdown="Revised plan")
opencode, repository, prompts, services = objects(
[serious_report(), revised, clean_report()], plan_rounds=4
)
run = RecordingRun()
original = workflow()
initial = PlanArtifact(plan_markdown="Initial plan")
updated, artifact, report = await review_plan_loop(
original, "issue context", initial, run, services
)
assert artifact is revised
assert report == clean_report()
assert updated.reviewer_session_id == "plan-review-session"
assert updated.artifact == "Revised plan"
assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"]
assert [call["session_id"] for call in opencode.resume_calls] == [
"plan-review-session",
"primary-session",
"plan-review-session",
]
assert [name for name, _ in prompts.calls] == [
"plan_review",
"plan_revision",
"plan_review",
]
assert opencode.responses == []
async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None:
final_report = serious_report("round 2")
opencode, repository, _, services = objects(
[
serious_report("round 1"),
PlanArtifact(plan_markdown="Only revision"),
final_report,
],
plan_rounds=2,
)
run = RecordingRun()
updated, artifact, report = await review_plan_loop(
workflow(),
"issue context",
PlanArtifact(plan_markdown="Initial plan"),
run,
services,
)
assert artifact.plan_markdown == "Only revision"
assert report is final_report
assert updated.artifact == "Only revision"
assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [
ReviewReport,
PlanArtifact,
ReviewReport,
]
assert run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"]
assert opencode.responses == []
async def test_plan_review_once_creates_and_persists_reviewer_session() -> None:
opencode, repository, prompts, services = objects([clean_report()])
original = workflow()
updated, report = await review_plan_once(
original,
"issue context",
PlanArtifact(plan_markdown="Plan body"),
services,
)
assert report == clean_report()
assert updated is not original
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "plan-review-session"
assert repository.saved_workflows == [updated]
assert opencode.created_sessions == [(original.workspace_path, "plan-review")]
assert opencode.resume_calls[0]["session_id"] == "plan-review-session"
assert opencode.resume_calls[0]["result_type"] is ReviewReport
assert prompts.calls == [
(
"plan_review",
{"context": "issue context", "artifact": "Plan body"},
)
]
async def test_minor_findings_end_review_loop_without_revision() -> None:
opencode, repository, _, services = objects([minor_report()], implementation_rounds=5)
run = RecordingRun()
initial = AgentResult(summary_markdown="initial", tests=[])
updated, result, report = await review_implementation_loop(
workflow(),
"issue context",
"canonical plan",
initial,
run,
services,
)
assert result is initial
assert report == minor_report()
assert not report.has_serious_findings assert not report.has_serious_findings
assert opencode.reviews == 2 assert updated.artifact == initial.model_dump_json()
assert opencode.revisions == 1 assert updated.review_json == minor_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [ReviewReport]
async def test_does_not_make_unreviewed_final_revision() -> None: assert run.stages == ["reviewing implementation 1/5"]
loop, opencode, workflow, job = objects(
3, [serious_report(), serious_report(), serious_report()]
)
_, report = await loop.run(
job,
workflow,
"issue context",
"canonical plan",
AgentResult(summary_markdown="initial", tests=[]),
)
assert report.has_serious_findings
assert opencode.reviews == 3
assert opencode.revisions == 2
+86 -7
View File
@@ -1,13 +1,17 @@
from pathlib import Path from pathlib import Path
from agentci.adapters.codegraph import CodeGraphClient import pytest
from agentci.codegraph import CodeGraph, CodeGraphError
class FakeProcess: class FakeProcess:
returncode = 0 def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
self.returncode = returncode
self.stderr = stderr
async def communicate(self) -> tuple[bytes, bytes]: async def communicate(self) -> tuple[bytes, bytes]:
return b"", b"" return b"", self.stderr
async def test_initializes_incomplete_index_and_excludes_it_from_git( async def test_initializes_incomplete_index_and_excludes_it_from_git(
@@ -23,11 +27,11 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec", "agentci.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
await CodeGraphClient().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "init", str(workspace))] assert calls == [("codegraph", "init", str(workspace))]
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n" assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
@@ -49,11 +53,86 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec", "agentci.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
await CodeGraphClient().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "sync", str(workspace))] assert calls == [("codegraph", "sync", str(workspace))]
assert exclude.read_text() == "# local excludes\n.codegraph/\n" assert exclude.read_text() == "# local excludes\n.codegraph/\n"
@pytest.mark.parametrize(
("initial", "expected"),
[
("*.pyc", "*.pyc\n.codegraph/\n"),
("# .codegraph/ is documented here\n", "# .codegraph/ is documented here\n.codegraph/\n"),
(" .codegraph/ \n", " .codegraph/ \n"),
],
)
async def test_handles_exclude_file_boundaries(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
initial: str,
expected: str,
) -> None:
workspace = tmp_path / "repo"
exclude = workspace / ".git" / "info" / "exclude"
exclude.parent.mkdir(parents=True)
exclude.write_text(initial)
async def create_subprocess_exec(*_args, **_kwargs):
return FakeProcess()
monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
await CodeGraph().prepare(workspace)
assert exclude.read_text() == expected
async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
async def create_subprocess_exec(*_args, **_kwargs):
raise FileNotFoundError(2, "No such file or directory", "codegraph")
monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised:
await CodeGraph().prepare(workspace)
assert isinstance(raised.value.__cause__, FileNotFoundError)
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail"
async def create_subprocess_exec(*_args, **_kwargs):
return FakeProcess(returncode=7, stderr=stderr)
monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
with pytest.raises(CodeGraphError, match="codegraph init failed") as raised:
await CodeGraph().prepare(workspace)
message = str(raised.value)
assert "discarded-prefix" not in message
assert "useful-tail" in message
assert len(message.removeprefix("codegraph init failed: ")) == 1000
+4 -8
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.models import CommandName, JobKind from agentci.engine.model import CommandName, JobKind
def test_ignores_non_commands() -> None: def test_ignores_non_commands() -> None:
@@ -14,8 +14,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
name: CommandName, line_breaks: int name: CommandName, line_breaks: int
) -> None: ) -> None:
command = parse_command( command = parse_command(
f"/agent {name.value}{'\n' * line_breaks}" f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests"
"focus on the API\nand add tests"
) )
assert command is not None assert command is not None
assert command.name is name assert command.name is name
@@ -26,10 +25,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
def test_all_commands_accept_crlf_separated_multiline_messages( def test_all_commands_accept_crlf_separated_multiline_messages(
name: CommandName, name: CommandName,
) -> None: ) -> None:
command = parse_command( command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests")
f"/agent {name.value}\r\n\r\n\r\n"
"focus on the API\r\nand add tests"
)
assert command is not None assert command is not None
assert command.name is name assert command.name is name
assert command.message == "focus on the API\r\nand add tests" assert command.message == "focus on the API\r\nand add tests"
+132 -43
View File
@@ -1,61 +1,150 @@
import os
from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from agentci.config import Settings from agentci.config import Settings
def test_parses_comma_delimited_install_scripts() -> None: @pytest.fixture(autouse=True)
settings = Settings( def isolate_agentci_environment(monkeypatch: pytest.MonkeyPatch) -> None:
_env_file=None, # type: ignore[call-arg] for name in tuple(os.environ):
install_scripts=" python, dotnet, company-tools, ", if name.startswith("AGENTCI_"):
monkeypatch.delenv(name)
def settings(**overrides: object) -> Settings:
return Settings(_env_file=None, **overrides) # type: ignore[arg-type,call-arg]
def test_reads_values_from_an_isolated_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
monkeypatch.setenv("AGENTCI_MAX_CONCURRENT_JOBS", "7")
value = settings()
assert value.install_scripts == ["python", "dotnet"]
assert value.max_concurrent_jobs == 7
@pytest.mark.parametrize("field", ["gitea_url", "opencode_url"])
def test_normalizes_service_urls(field: str) -> None:
value = settings(**{field: "https://service.example/base///"})
assert getattr(value, field) == "https://service.example/base"
@pytest.mark.parametrize(
("field", "boundaries"),
[
("plan_review_rounds", (1, 20)),
("implement_review_rounds", (1, 20)),
("turn_timeout_seconds", (60,)),
("install_script_timeout_seconds", (1,)),
("worker_poll_seconds", (0.1,)),
("max_concurrent_jobs", (1, 32)),
],
)
def test_accepts_documented_numeric_boundaries(
field: str, boundaries: tuple[int | float, ...]
) -> None:
for boundary in boundaries:
assert getattr(settings(**{field: boundary}), field) == boundary
@pytest.mark.parametrize(
("field", "value"),
[
("plan_review_rounds", 0),
("plan_review_rounds", 21),
("implement_review_rounds", 0),
("implement_review_rounds", 21),
("turn_timeout_seconds", 59),
("install_script_timeout_seconds", 0),
("worker_poll_seconds", 0.09),
("max_concurrent_jobs", 0),
("max_concurrent_jobs", 33),
],
)
def test_rejects_values_outside_numeric_boundaries(field: str, value: int | float) -> None:
with pytest.raises(ValidationError):
settings(**{field: value})
def test_reads_and_strips_secret_files(tmp_path: Path) -> None:
token_file = tmp_path / "token"
webhook_file = tmp_path / "webhook"
password_file = tmp_path / "password"
token_file.write_text(" gitea-token\n")
webhook_file.write_text(" webhook-secret \n")
password_file.write_text("opencode-password\n")
value = settings(
gitea_token_file=token_file,
webhook_secret_file=webhook_file,
opencode_server_password_file=password_file,
) )
assert settings.install_scripts == ["python", "dotnet", "company-tools"] assert value.gitea_token == "gitea-token"
assert value.webhook_secret == b"webhook-secret"
assert value.opencode_server_password == "opencode-password"
def test_missing_secret_file_has_actionable_error(tmp_path: Path) -> None:
missing = tmp_path / "missing-token"
value = settings(gitea_token_file=missing)
with pytest.raises(RuntimeError, match="Cannot read Gitea token") as raised:
_ = value.gitea_token
assert str(missing) in str(raised.value)
def test_empty_secret_file_is_rejected(tmp_path: Path) -> None:
empty = tmp_path / "webhook"
empty.write_text(" \n")
value = settings(webhook_secret_file=empty)
with pytest.raises(RuntimeError, match="webhook secret file") as raised:
_ = value.webhook_secret
assert str(empty) in str(raised.value)
def test_derived_state_paths_follow_data_directory(tmp_path: Path) -> None:
data_dir = tmp_path / "state"
value = settings(data_dir=data_dir)
assert value.database_path == data_dir / "agentci.sqlite3"
assert value.workspaces_dir == data_dir / "workspaces"
def test_parses_comma_delimited_install_scripts() -> None:
value = settings(install_scripts=" python, dotnet, company-tools, ")
assert value.install_scripts == ["python", "dotnet", "company-tools"]
@pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"]) @pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None: def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Settings(_env_file=None, install_scripts=value) # type: ignore[call-arg] settings(install_scripts=value)
def test_empty_install_scripts_disable_setup() -> None: @pytest.mark.parametrize("value", ["", None, []])
settings = Settings( def test_empty_install_scripts_disable_setup(value: object) -> None:
_env_file=None, # type: ignore[call-arg] assert settings(install_scripts=value).install_scripts == []
install_scripts="",
def test_agent_defaults_select_expected_capacity_and_research_models() -> None:
value = settings()
assert value.max_concurrent_jobs == 2
assert (value.explore_model, value.explore_variant) == (
"openai/gpt-5.6-luna",
"low",
) )
assert settings.install_scripts == [] assert value.research_variant == "high"
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"
def test_defaults_explore_agent_to_luna_low() -> None:
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.explore_model == "openai/gpt-5.6-luna"
assert settings.explore_variant == "low"
def test_defaults_to_two_concurrent_jobs() -> None:
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.max_concurrent_jobs == 2
@pytest.mark.parametrize("value", [0, 33])
def test_rejects_unsafe_job_concurrency(value: int) -> None:
with pytest.raises(ValidationError):
Settings(_env_file=None, max_concurrent_jobs=value) # type: ignore[call-arg]
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.install_scripts == ["python", "dotnet"]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -63,4 +152,4 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) ->
) )
def test_requires_provider_qualified_opencode_models(field: str) -> None: def test_requires_provider_qualified_opencode_models(field: str) -> None:
with pytest.raises(ValidationError, match="provider/model"): with pytest.raises(ValidationError, match="provider/model"):
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg] settings(**{field: "model-only"})
+193 -6
View File
@@ -1,8 +1,11 @@
from agentci.adapters.gitea_models import CommentInfo, IssueInfo from typing import cast
from agentci.workflows.context import ContextBuilder
from agentci.engine.repository import Repository
from agentci.gitea import CommentInfo, Gitea, IssueInfo, PullRequestInfo
from agentci.workflows.context import build_issue_context, build_pull_request_context
class FakeGitea: class FakeIssueGitea:
async def issue(self, *_args): async def issue(self, *_args):
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open") return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
@@ -13,15 +16,199 @@ class FakeGitea:
] ]
class FakeStorage: class FakeRepository:
async def operational_comment_ids(self, *_args): async def operational_comment_ids(self, *_args):
return {2} return {2}
class FakePullRequestGitea:
async def pull_request(self, *_args):
return PullRequestInfo(
number=3,
title="Fix widget",
body="Fixes the failure.",
state="open",
merged=False,
base_branch="main",
head_branch="fix-widget",
head_sha="abcdef1234567890",
head_owner="alice",
head_repo="repo",
)
async def issue_comments(self, *_args):
return [CommentInfo(3, "bob", "Please add a test.", "2026-01-03")]
async def pull_reviews(self, *_args):
return [
{
"id": 4,
"user": {"login": "carol"},
"state": "REQUEST_CHANGES",
"body": "One issue remains.",
}
]
async def review_comments(self, *_args):
return [
{
"path": "src/widget.py",
"new_position": 12,
"body": "Handle the empty value.",
}
]
async def pull_commits(self, *_args):
return [{"sha": "abcdef1234567890", "commit": {"message": "Fix widget"}}]
class EmptyIssueGitea:
async def issue(self, *_args):
return IssueInfo(number=5, title="Empty issue", body="", state="open")
async def issue_comments(self, *_args):
return []
class EmptyRepository:
async def operational_comment_ids(self, *_args):
return set()
class EmptyPullRequestGitea:
async def pull_request(self, *_args):
return PullRequestInfo(
number=6,
title="Empty pull request",
body="",
state="open",
merged=False,
base_branch="main",
head_branch="empty",
head_sha="1234567890abcdef",
head_owner="alice",
head_repo="repo",
)
async def issue_comments(self, *_args):
return []
async def pull_reviews(self, *_args):
return []
async def pull_commits(self, *_args):
return []
async def review_comments(self, *_args):
raise AssertionError("review comments should not be requested without reviews")
class OrderedPullRequestGitea(FakePullRequestGitea):
async def issue_comments(self, *_args):
return [
CommentInfo(1, "alice", "First timeline comment", "2026-01-01"),
CommentInfo(2, "bob", "Second timeline comment", "2026-01-02"),
]
async def pull_reviews(self, *_args):
return [
{
"id": 10,
"user": {"login": "carol"},
"state": "REQUEST_CHANGES",
"body": "First formal review",
},
{
"id": 11,
"user": {"login": "dave"},
"state": "APPROVED",
"body": "Second formal review",
},
]
async def review_comments(self, *_args):
review_id = _args[-1]
if review_id == 10:
return [
{
"path": "src/old.py",
"new_position": None,
"old_position": 21,
"body": "Old-side position",
}
]
return [
{
"path": "src/new.py",
"new_position": 34,
"body": "New-side position",
}
]
async def pull_commits(self, *_args):
return [
{"sha": "111111111111aaaa", "commit": {"message": "First commit"}},
{"sha": "222222222222bbbb", "commit": {"message": "Second commit"}},
]
async def test_issue_context_excludes_operational_comments() -> None: async def test_issue_context_excludes_operational_comments() -> None:
builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type] context = await build_issue_context(
context = await builder.issue_context("org", "repo", 2) cast(Gitea, FakeIssueGitea()),
cast(Repository, FakeRepository()),
"org",
"repo",
2,
)
assert "Broken widget" in context assert "Broken widget" in context
assert "Details" in context assert "Details" in context
assert "Agent job queued" not in context assert "Agent job queued" not in context
async def test_pull_request_context_includes_feedback_and_commits() -> None:
pull, context = await build_pull_request_context(
cast(Gitea, FakePullRequestGitea()), "org", "repo", 3
)
assert pull.number == 3
assert "Please add a test." in context
assert "One issue remains." in context
assert "`src/widget.py:12`: Handle the empty value." in context
assert "abcdef123456 Fix widget" in context
async def test_issue_context_labels_empty_body_and_discussion() -> None:
context = await build_issue_context(
cast(Gitea, EmptyIssueGitea()),
cast(Repository, EmptyRepository()),
"org",
"repo",
5,
)
assert "## Issue body\n(empty)" in context
assert "## Discussion\n(none)" in context
async def test_pull_request_context_labels_empty_sections() -> None:
_, context = await build_pull_request_context(
cast(Gitea, EmptyPullRequestGitea()), "org", "repo", 6
)
assert "## Pull request body\n(empty)" in context
assert "## Commits\n(none)" in context
assert "## Timeline discussion\n(none)" in context
assert "## Formal and inline reviews\n(none)" in context
async def test_pull_request_context_preserves_source_order_and_positions() -> None:
_, context = await build_pull_request_context(
cast(Gitea, OrderedPullRequestGitea()), "org", "repo", 3
)
assert context.index("First timeline comment") < context.index("Second timeline comment")
assert context.index("111111111111 First commit") < context.index("222222222222 Second commit")
assert context.index("Review 10 by carol") < context.index("Review 11 by dave")
assert "`src/old.py:21`: Old-side position" in context
assert "`src/new.py:34`: New-side position" in context
+72 -2
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from agentci.adapters.development import ( from agentci.development import (
DevelopmentEnvironment, DevelopmentEnvironment,
DevelopmentEnvironmentError, DevelopmentEnvironmentError,
) )
@@ -37,7 +37,7 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
development = environment(tmp_path, ["first", "second"]) development = environment(tmp_path, ["first", "second"])
script( script(
development.scripts_dir / "first", development.scripts_dir / "first",
"printf 'first:%s:%s\\n' \"$DEV_TOOLS_DIR\" \"${AGENTCI_SECRET-unset}\" >> order", 'printf \'first:%s:%s\\n\' "$DEV_TOOLS_DIR" "${AGENTCI_SECRET-unset}" >> order',
) )
script(development.scripts_dir / "second", "printf 'second\\n' >> order") script(development.scripts_dir / "second", "printf 'second\\n' >> order")
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak") monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
@@ -112,6 +112,76 @@ async def test_reports_script_failure_output(tmp_path) -> None:
await development.prepare(workspace) await development.prepare(workspace)
async def test_reports_missing_install_script(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["missing"])
with pytest.raises(
DevelopmentEnvironmentError,
match=r"Install script 'missing' was not found",
):
await development.prepare(workspace)
async def test_stops_before_second_script_after_failure(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["first", "second"])
script(development.scripts_dir / "first", "printf 'first\n' > first-ran; exit 3")
script(development.scripts_dir / "second", "printf 'second\n' > second-ran")
with pytest.raises(DevelopmentEnvironmentError, match="exited with 3"):
await development.prepare(workspace)
assert (workspace / "first-ran").read_text() == "first\n"
assert not (workspace / "second-ran").exists()
async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["verbose"])
script(
development.scripts_dir / "verbose",
"printf 'discarded-prefix' >&2; "
'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; '
"printf 'useful-tail' >&2; exit 9",
)
with pytest.raises(DevelopmentEnvironmentError) as raised:
await development.prepare(workspace)
message = str(raised.value)
assert "discarded-prefix" not in message
assert message.endswith("useful-tail")
async def test_wraps_subprocess_start_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["broken"])
script(development.scripts_dir / "broken", "true")
async def create_subprocess_exec(*_args, **_kwargs):
raise OSError("exec unavailable")
monkeypatch.setattr(
"agentci.development.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
with pytest.raises(
DevelopmentEnvironmentError,
match="Could not run install script 'broken': exec unavailable",
) as raised:
await development.prepare(workspace)
assert isinstance(raised.value.__cause__, OSError)
async def test_times_out_install_script(tmp_path) -> None: async def test_times_out_install_script(tmp_path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
+247
View File
@@ -0,0 +1,247 @@
import asyncio
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import pytest
from agentci.git import Git, GitError
class FakeProcess:
def __init__(
self,
stdout: bytes = b"",
stderr: bytes = b"",
returncode: int = 0,
) -> None:
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
async def communicate(self) -> tuple[bytes, bytes]:
return self.stdout, self.stderr
class SubprocessRecorder:
def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None:
self.outcomes = list(outcomes)
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess:
self.calls.append((args, kwargs))
outcome = self.outcomes.pop(0)
if isinstance(outcome, OSError):
raise outcome
return outcome
@property
def commands(self) -> list[tuple[Any, ...]]:
return [args for args, _ in self.calls]
def git_client(tmp_path: Path) -> Git:
return Git(
gitea_url="https://git.example.test/",
username="agent-user",
token="secret-token",
askpass_path=tmp_path / "askpass.sh",
commit_name="Agent CI",
commit_email="agent@example.test",
)
def install_recorder(
monkeypatch: pytest.MonkeyPatch,
outcomes: Sequence[FakeProcess | OSError],
) -> SubprocessRecorder:
recorder = SubprocessRecorder(outcomes)
monkeypatch.setattr(asyncio, "create_subprocess_exec", recorder)
return recorder
async def test_clone_uses_authenticated_remote_and_returns_head(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
for name in (
"GIT_ASKPASS",
"GIT_TERMINAL_PROMPT",
"AGENTCI_GIT_USERNAME",
"AGENTCI_GIT_PASSWORD",
):
monkeypatch.delenv(name, raising=False)
recorder = install_recorder(
monkeypatch,
[FakeProcess(), FakeProcess(stdout=b"abc123\n")],
)
destination = tmp_path / "workspaces" / "repo"
sha = await git_client(tmp_path).clone("org", "repo", "main", destination)
assert sha == "abc123"
assert destination.parent.is_dir()
assert recorder.commands == [
(
"git",
"clone",
"--branch",
"main",
"--single-branch",
"https://git.example.test/org/repo.git",
str(destination),
),
("git", "rev-parse", "HEAD"),
]
clone_kwargs = recorder.calls[0][1]
assert clone_kwargs["cwd"] == destination.parent
assert clone_kwargs["stdout"] is asyncio.subprocess.PIPE
assert clone_kwargs["stderr"] is asyncio.subprocess.PIPE
assert (
clone_kwargs["env"]
| {
"GIT_ASKPASS": str(tmp_path / "askpass.sh"),
"GIT_TERMINAL_PROMPT": "0",
"AGENTCI_GIT_USERNAME": "agent-user",
"AGENTCI_GIT_PASSWORD": "secret-token",
}
== clone_kwargs["env"]
)
current_sha_environment = recorder.calls[1][1]["env"]
assert "AGENTCI_GIT_PASSWORD" not in current_sha_environment
async def test_sync_branch_resets_and_cleans_before_returning_sha(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
recorder = install_recorder(
monkeypatch,
[FakeProcess(), FakeProcess(), FakeProcess(), FakeProcess(stdout=b"new-sha\n")],
)
workspace = tmp_path / "repo"
sha = await git_client(tmp_path).sync_branch(workspace, "feature")
assert sha == "new-sha"
assert recorder.commands == [
(
"git",
"fetch",
"origin",
"refs/heads/feature:refs/remotes/origin/feature",
),
("git", "reset", "--hard", "origin/feature"),
("git", "clean", "-fd"),
("git", "rev-parse", "HEAD"),
]
assert all(call[1]["cwd"] == workspace for call in recorder.calls)
assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token"
async def test_branch_status_and_diff_commands(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
recorder = install_recorder(
monkeypatch,
[FakeProcess(), FakeProcess(stdout=b" M src/app.py\n"), FakeProcess()],
)
workspace = tmp_path / "repo"
git = git_client(tmp_path)
await git.create_branch(workspace, "agent/issue-1")
changed = await git.has_changes(workspace)
await git.diff_check(workspace)
assert changed is True
assert recorder.commands == [
("git", "switch", "-c", "agent/issue-1"),
("git", "status", "--porcelain"),
("git", "diff", "--check"),
]
async def test_has_changes_is_false_for_clean_status(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
install_recorder(monkeypatch, [FakeProcess(stdout=b"\n")])
assert await git_client(tmp_path).has_changes(tmp_path / "repo") is False
async def test_commit_stages_all_changes_sets_identity_and_returns_sha(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
recorder = install_recorder(
monkeypatch,
[FakeProcess(), FakeProcess(), FakeProcess(stdout=b"commit-sha\n")],
)
workspace = tmp_path / "repo"
sha = await git_client(tmp_path).commit(workspace, "agent: Fix widget")
assert sha == "commit-sha"
assert recorder.commands == [
("git", "add", "-A"),
(
"git",
"-c",
"user.name=Agent CI",
"-c",
"user.email=agent@example.test",
"commit",
"-m",
"agent: Fix widget",
),
("git", "rev-parse", "HEAD"),
]
async def test_push_command_supports_initial_and_existing_branches(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
recorder = install_recorder(monkeypatch, [FakeProcess(), FakeProcess()])
workspace = tmp_path / "repo"
git = git_client(tmp_path)
await git.push(workspace, "agent/new", set_upstream=True)
await git.push(workspace, "agent/existing")
assert recorder.commands == [
("git", "push", "--set-upstream", "origin", "agent/new"),
("git", "push", "origin", "HEAD:agent/existing"),
]
assert all(call[1]["env"]["GIT_TERMINAL_PROMPT"] == "0" for call in recorder.calls)
async def test_git_translates_process_start_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
install_recorder(monkeypatch, [OSError("git executable missing")])
with pytest.raises(GitError, match="Could not run git rev-parse") as error:
await git_client(tmp_path).current_sha(tmp_path / "repo")
assert isinstance(error.value.__cause__, OSError)
async def test_git_translates_nonzero_exit_and_stderr(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
install_recorder(
monkeypatch,
[FakeProcess(stderr=b"fatal: invalid diff\n", returncode=2)],
)
with pytest.raises(GitError, match="git diff failed: fatal: invalid diff"):
await git_client(tmp_path).diff_check(tmp_path / "repo")
async def test_commit_failure_identifies_commit_operation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
install_recorder(
monkeypatch,
[FakeProcess(), FakeProcess(stderr=b"nothing to commit\n", returncode=1)],
)
with pytest.raises(GitError, match="git commit failed: nothing to commit"):
await git_client(tmp_path).commit(tmp_path / "repo", "agent: change")
+334 -12
View File
@@ -1,22 +1,344 @@
import json import json
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import asynccontextmanager
import httpx import httpx
import respx import pytest
from agentci.adapters.gitea import GiteaClient from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
@respx.mock @asynccontextmanager
async def test_updates_issue_comment_by_id() -> None: async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]:
route = respx.patch( client = Gitea(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17" "https://gitea.example/",
).mock(return_value=httpx.Response(200, json={"id": 17})) "secret",
client = GiteaClient("https://gitea.example", "secret") retries=retries,
transport=httpx.MockTransport(handler),
)
try: try:
await client.update_comment("org", "repo", 17, "updated status") yield client
finally: finally:
await client.close() await client.close()
assert route.called
assert json.loads(route.calls[0].request.content) == {"body": "updated status"} async def test_sends_authenticated_json_request_contract() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"id": 17})
async with gitea_client(handler) as client:
assert await client.update_comment("org", "repo", 17, "updated status")
assert len(requests) == 1
request = requests[0]
assert request.method == "PATCH"
assert request.url == httpx.URL(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
)
assert request.headers["authorization"] == "token secret"
assert request.headers["accept"] == "application/json"
assert request.headers["content-type"] == "application/json"
assert json.loads(request.content) == {"body": "updated status"}
@pytest.mark.parametrize(
("permission", "expected"),
[("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)],
)
async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission"
return httpx.Response(200, json={"permission": permission})
async with gitea_client(handler) as client:
assert await client.has_write_permission("org", "repo", "alice") is expected
async def test_maps_issue_and_pull_request_responses() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
match request.url.path:
case "/api/v1/repos/org/repo":
return httpx.Response(200, json={"default_branch": "trunk"})
case "/api/v1/repos/org/repo/issues/12":
return httpx.Response(
200,
json={"title": "Issue title", "body": None, "state": "open"},
)
case "/api/v1/repos/org/repo/pulls/8":
return httpx.Response(
200,
json={
"title": "Pull title",
"body": None,
"state": "open",
"merged": False,
"base": {"ref": "trunk"},
"head": {
"ref": "feature",
"sha": "abc123",
"repo": {"owner": {"login": "fork-owner"}, "name": "fork"},
},
},
)
raise AssertionError(f"unexpected request: {request.url}")
async with gitea_client(handler) as client:
branch = await client.default_branch("org", "repo")
issue = await client.issue("org", "repo", 12)
pull = await client.pull_request("org", "repo", 8)
assert branch == "trunk"
assert issue == IssueInfo(number=12, title="Issue title", body="", state="open")
assert pull == PullRequestInfo(
number=8,
title="Pull title",
body="",
state="open",
merged=False,
base_branch="trunk",
head_branch="feature",
head_sha="abc123",
head_owner="fork-owner",
head_repo="fork",
)
assert pull.is_open
async def test_creates_comment_and_pull_request_with_expected_payloads() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/issues/4/comments"):
return httpx.Response(201, json={"id": "23"})
if request.method == "POST" and request.url.path.endswith("/pulls"):
return httpx.Response(201, json={"number": 9})
if request.method == "GET" and request.url.path.endswith("/pulls/9"):
return httpx.Response(
200,
json={
"title": "Implement it",
"body": "Details",
"state": "open",
"merged": False,
"base": {"ref": "main"},
"head": {
"ref": "agent/work",
"sha": "def456",
"repo": {"owner": {"login": "org"}, "name": "repo"},
},
},
)
raise AssertionError(f"unexpected request: {request.method} {request.url}")
async with gitea_client(handler) as client:
comment_id = await client.create_comment("org", "repo", 4, "Working")
pull = await client.create_pull_request(
"org",
"repo",
title="Implement it",
body="Details",
head="agent/work",
base="main",
)
assert comment_id == 23
assert pull.number == 9
assert [(request.method, request.url.path) for request in requests] == [
("POST", "/api/v1/repos/org/repo/issues/4/comments"),
("POST", "/api/v1/repos/org/repo/pulls"),
("GET", "/api/v1/repos/org/repo/pulls/9"),
]
assert json.loads(requests[0].content) == {"body": "Working"}
assert json.loads(requests[1].content) == {
"title": "Implement it",
"body": "Details",
"head": "agent/work",
"base": "main",
}
async def test_maps_allowed_not_found_responses_without_retry() -> None:
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
return httpx.Response(404)
async with gitea_client(handler) as client:
updated = await client.update_comment("org", "repo", 99, "missing")
comments = await client.review_comments("org", "repo", 7, 3)
assert not updated
assert comments == []
assert paths == [
"/api/v1/repos/org/repo/issues/comments/99",
"/api/v1/repos/org/repo/pulls/7/reviews/3/comments",
]
@pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])])
async def test_pagination_stops_only_after_a_short_page(
first_page_size: int, expected_pages: list[int]
) -> None:
pages: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments"
assert request.url.params["limit"] == "50"
page = int(request.url.params["page"])
pages.append(page)
size = first_page_size if page == 1 else 1
offset = 0 if page == 1 else 50
return httpx.Response(
200,
json=[
{
"id": offset + index + 1,
"user": {"login": f"user-{offset + index + 1}"},
"body": None,
"created_at": None,
}
for index in range(size)
],
)
async with gitea_client(handler) as client:
comments = await client.issue_comments("org", "repo", 6)
expected_count = first_page_size + (1 if first_page_size == 50 else 0)
assert pages == expected_pages
assert len(comments) == expected_count
if comments:
assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="")
assert comments[-1].id == expected_count
@pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(status)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
with pytest.raises(
GiteaError,
match=rf"Gitea returned {status} for GET /repos/org/repo",
):
await client.default_branch("org", "repo")
assert attempts == 1
assert sleeps == []
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
async def test_retryable_status_recovers_after_backoff(
status: int, monkeypatch: pytest.MonkeyPatch
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts == 1:
return httpx.Response(status)
return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == 2
assert sleeps == [1]
async def test_retryable_status_exhaustion_uses_exponential_backoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(503)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
with pytest.raises(
GiteaError,
match="Gitea remained unavailable for GET /repos/org/repo",
):
await client.default_branch("org", "repo")
assert attempts == 3
assert sleeps == [1, 2]
async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts < 3:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == 3
assert sleeps == [1, 2]
async def test_transport_failure_exhaustion_preserves_cause(
monkeypatch: pytest.MonkeyPatch,
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ConnectError("connection refused", request=request)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler, retries=2) as client:
with pytest.raises(
GiteaError,
match="Gitea request failed: GET /repos/org/repo",
) as raised:
await client.default_branch("org", "repo")
assert isinstance(raised.value.__cause__, httpx.ConnectError)
assert attempts == 2
assert sleeps == [1]
+77
View File
@@ -0,0 +1,77 @@
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response
from agentci.health import router
class Provider:
def __init__(self, result: bool | Exception) -> None:
self.result = result
self.calls = 0
async def ready(self) -> bool:
self.calls += 1
if isinstance(self.result, Exception):
raise self.result
return self.result
def application(provider: Provider | None = None) -> FastAPI:
app = FastAPI()
app.include_router(router)
if provider is not None:
app.state.runtime = SimpleNamespace(opencode=provider)
return app
async def get(app: FastAPI, path: str) -> Response:
async with AsyncClient(
transport=ASGITransport(app=app, raise_app_exceptions=False),
base_url="http://test",
) as client:
return await client.get(path)
async def test_liveness_does_not_depend_on_runtime_providers() -> None:
response = await get(application(), "/health/live")
assert response.status_code == 200
assert response.json() == {"status": "live"}
@pytest.mark.parametrize(
("provider_ready", "status_code", "payload"),
[
(True, 200, {"status": "ready"}),
(
False,
503,
{
"status": "not-ready",
"reason": "opencode provider is not connected",
},
),
],
)
async def test_readiness_reflects_provider_state(
provider_ready: bool, status_code: int, payload: dict[str, str]
) -> None:
provider = Provider(provider_ready)
response = await get(application(provider), "/health/ready")
assert response.status_code == status_code
assert response.json() == payload
assert provider.calls == 1
async def test_readiness_provider_error_is_server_failure() -> None:
provider = Provider(RuntimeError("provider check failed"))
response = await get(application(provider), "/health/ready")
assert response.status_code == 500
assert provider.calls == 1
-143
View File
@@ -1,143 +0,0 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from agentci.adapters.gitea_models import IssueInfo, PullRequestInfo, RepositoryInfo
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.workflows.implement import ImplementWorkflow
from agentci.workflows.pull_request import PullRequestWorkflow
class SetupReached(RuntimeError):
pass
class FakeDevelopment:
description = "python"
def __init__(self, events: list[str]) -> None:
self.events = events
async def prepare(self, _workspace: Path) -> None:
self.events.append("prepare")
raise SetupReached
class FakeGit:
def __init__(self, events: list[str]) -> None:
self.events = events
async def clone(self, *_args) -> str:
self.events.append("clone")
return "base-sha"
async def create_branch(self, *_args) -> None:
self.events.append("create branch")
async def sync_branch(self, *_args) -> None:
self.events.append("sync branch")
class FakeStorage:
def __init__(self, workflow: Workflow | None = None) -> None:
self.workflow = workflow
async def implementation_workflows(self, *_args):
return []
async def create_workflow(self, _workflow) -> None:
return None
async def update_job(self, *_args, **_kwargs) -> None:
return None
async def workflow_for_pr(self, *_args):
return self.workflow
class FakeContext:
def __init__(self, pull: PullRequestInfo) -> None:
self.pull = pull
async def pull_request_context(self, *_args):
return self.pull, "context"
class FakeGitea:
async def repository(self, *_args) -> RepositoryInfo:
return RepositoryInfo("org", "repo", "org/repo", "main")
async def issue(self, *_args) -> IssueInfo:
return IssueInfo(1, "Issue", "Body", "open")
def job(kind: JobKind, *, pr_number: int | None = None) -> Job:
return Job(
id="job",
kind=kind,
target_key="org/repo:target",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=pr_number,
requester="alice",
message="",
comment_id=1,
)
def pull() -> PullRequestInfo:
return PullRequestInfo(2, "PR", "Body", "open", False, "main", "agent", "sha", "org", "repo")
async def test_initial_implementation_prepares_after_clone_and_branch(tmp_path) -> None:
events: list[str] = []
settings = SimpleNamespace(branch_prefix="agent", workspaces_dir=tmp_path)
deps = SimpleNamespace(
settings=settings,
storage=FakeStorage(),
gitea=FakeGitea(),
git=FakeGit(events),
development=FakeDevelopment(events),
)
with pytest.raises(SetupReached):
await ImplementWorkflow(deps).run(job(JobKind.IMPLEMENT)) # type: ignore[arg-type]
assert events == ["clone", "create branch", "prepare"]
@pytest.mark.parametrize("operation", ["iterate", "fix"])
async def test_pull_request_implementation_prepares_after_checkout(
tmp_path, operation: str
) -> None:
events: list[str] = []
existing = Workflow(
id="flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=tmp_path / "repo",
base_sha="base",
branch="agent",
primary_session_id="primary",
reviewer_session_id="reviewer",
status=WorkflowStatus.COMPLETED,
)
settings = SimpleNamespace(workspaces_dir=tmp_path)
deps = SimpleNamespace(
settings=settings,
storage=FakeStorage(existing),
context=FakeContext(pull()),
git=FakeGit(events),
development=FakeDevelopment(events),
)
workflow = PullRequestWorkflow(deps) # type: ignore[arg-type]
with pytest.raises(SetupReached):
await getattr(workflow, operation)(job(JobKind.FIX, pr_number=2))
expected_checkout = "sync branch" if operation == "iterate" else "clone"
assert events == [expected_checkout, "prepare"]
+150 -8
View File
@@ -1,12 +1,154 @@
import os
import subprocess
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).parents[1]
def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None:
root = Path(__file__).parents[1]
script = (root / "install-scripts" / "dotnet").read_text()
assert "export DOTNET_ROOT=" in script def executable(path: Path, content: str) -> None:
assert "/tmp/agentci-dotnet" not in script path.write_text(content)
assert "DEV_TOOLS_DIR/runtime/dotnet" in script path.chmod(0o755)
assert "NUGET_PACKAGES" in script
assert 'export HOME="$DOTNET_CLI_HOME"' in script
def test_python_installer_configures_uv_and_stable_python_links(tmp_path: Path) -> None:
fake_bin = tmp_path / "fake-bin"
fake_bin.mkdir()
tools = tmp_path / "tools"
uv_log = tmp_path / "uv.log"
executable(
fake_bin / "uv",
"""#!/bin/sh
set -eu
{
printf 'UV_NO_CONFIG=%s\n' "$UV_NO_CONFIG"
printf 'UV_PYTHON_INSTALL_DIR=%s\n' "$UV_PYTHON_INSTALL_DIR"
printf 'UV_PYTHON_BIN_DIR=%s\n' "$UV_PYTHON_BIN_DIR"
printf 'UV_PYTHON_INSTALL_BIN=%s\n' "$UV_PYTHON_INSTALL_BIN"
printf 'args=%s %s %s\n' "$1" "$2" "$3"
} > "$FAKE_UV_LOG"
minor=$(printf '%s' "$3" | cut -d. -f1,2)
mkdir -p "$UV_PYTHON_INSTALL_DIR" "$UV_PYTHON_BIN_DIR"
cat > "$UV_PYTHON_BIN_DIR/python$minor" <<'PYTHON'
#!/bin/sh
printf '%s\n' 'Python fake'
PYTHON
chmod 0755 "$UV_PYTHON_BIN_DIR/python$minor"
""",
)
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"DEV_TOOLS_DIR": str(tools),
"PYTHON_VERSION": "3.13.7",
"FAKE_UV_LOG": str(uv_log),
}
result = subprocess.run(
["/bin/sh", str(ROOT / "install-scripts" / "python")],
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout == "Python fake\n"
assert uv_log.read_text().splitlines() == [
"UV_NO_CONFIG=1",
f"UV_PYTHON_INSTALL_DIR={tools / 'python'}",
f"UV_PYTHON_BIN_DIR={tools / 'bin'}",
"UV_PYTHON_INSTALL_BIN=1",
"args=python install 3.13.7",
]
assert (tools / "bin" / "python").readlink() == Path("python3.13")
assert (tools / "bin" / "python3").readlink() == Path("python3.13")
def test_dotnet_installer_builds_persistent_runtime_wrapper_without_network(
tmp_path: Path,
) -> None:
fake_bin = tmp_path / "fake-bin"
fake_bin.mkdir()
tools = tmp_path / "tools"
installer_log = tmp_path / "installer.log"
dotnet_log = tmp_path / "dotnet.log"
executable(
fake_bin / "python3",
"""#!/bin/sh
set -eu
[ "$1" = - ]
cat > "$2" <<'INSTALLER'
#!/bin/sh
set -eu
printf '%s\n' "$@" > "$FAKE_INSTALLER_LOG"
install_dir=
while [ "$#" -gt 0 ]; do
case "$1" in
--install-dir) install_dir=$2; shift 2 ;;
*) shift ;;
esac
done
mkdir -p "$install_dir"
cat > "$install_dir/dotnet" <<'DOTNET'
#!/bin/sh
set -eu
printf '%s|%s|%s|%s|%s|%s\n' \
"$*" "$DOTNET_ROOT" "$DOTNET_CLI_HOME" "$NUGET_PACKAGES" \
"$NUGET_HTTP_CACHE_PATH" "$HOME" >> "$FAKE_DOTNET_LOG"
DOTNET
chmod 0755 "$install_dir/dotnet"
INSTALLER
""",
)
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"DEV_TOOLS_DIR": str(tools),
"DOTNET_CHANNEL": "10.0",
"FAKE_INSTALLER_LOG": str(installer_log),
"FAKE_DOTNET_LOG": str(dotnet_log),
}
result = subprocess.run(
["/bin/sh", str(ROOT / "install-scripts" / "dotnet")],
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, result.stderr
assert installer_log.read_text().splitlines() == [
"--channel",
"10.0",
"--install-dir",
str(tools / "dotnet"),
"--no-path",
]
wrapper = tools / "bin" / "dotnet"
assert os.access(wrapper, os.X_OK)
wrapped = subprocess.run(
[wrapper, "--version"],
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert wrapped.returncode == 0, wrapped.stderr
calls = dotnet_log.read_text().splitlines()
assert calls[0].split("|")[0] == "--info"
assert calls[1].split("|") == [
"--version",
str(tools / "dotnet"),
str(tools / "runtime" / "dotnet" / "home"),
str(tools / "runtime" / "dotnet" / "nuget" / "packages"),
str(tools / "runtime" / "dotnet" / "nuget" / "http-cache"),
str(tools / "runtime" / "dotnet" / "home"),
]
assert (tools / "runtime" / "dotnet" / "home").is_dir()
assert (tools / "runtime" / "dotnet" / "nuget" / "packages").is_dir()
+75
View File
@@ -0,0 +1,75 @@
from pathlib import Path
import pytest
from agentci.engine.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Workflow, WorkflowKind
from agentci.engine.run import JobRun
class RecordingRepository:
def __init__(self, *, fail_event_ids: set[str] | None = None) -> None:
self.events: list[tuple[str, object]] = []
self.fail_event_ids = fail_event_ids or set()
async def apply(self, event_id: str, event: object) -> None:
self.events.append((event_id, event))
if event_id in self.fail_event_ids:
raise RuntimeError("write failed")
async def test_job_run_emits_each_report_payload_with_monotonic_ids() -> None:
repository = RecordingRepository()
run = JobRun(repository, "job-1", 10) # type: ignore[arg-type]
workflow = Workflow(
id="workflow-1",
kind=WorkflowKind.PLAN,
repo_owner="alice",
repo_name="repo",
issue_number=3,
workspace_path=Path("/work/repo"),
base_sha="abc",
)
await run.stage("cloning")
await run.create_workflow(workflow, "planning")
await run.link_workflow("workflow-2", "discussing")
await run.link_session("session-1")
assert repository.events == [
("task:10:report:1", JobProgress(job_id="job-1", stage="cloning")),
(
"task:10:report:2",
WorkflowCreated(job_id="job-1", workflow=workflow, stage="planning"),
),
(
"task:10:report:3",
WorkflowLinked(job_id="job-1", workflow_id="workflow-2", stage="discussing"),
),
(
"task:10:report:4",
RuntimeSessionLinked(job_id="job-1", session_id="session-1"),
),
]
async def test_failed_report_consumes_only_its_run_sequence_number() -> None:
repository = RecordingRepository(fail_event_ids={"task:10:report:1"})
first = JobRun(repository, "job-1", 10) # type: ignore[arg-type]
second = JobRun(repository, "job-2", 20) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="write failed"):
await first.stage("cloning")
await second.stage("fixing")
await first.link_session("session-1")
assert [event_id for event_id, _ in repository.events] == [
"task:10:report:1",
"task:20:report:1",
"task:10:report:2",
]
-13
View File
@@ -1,13 +0,0 @@
from pathlib import Path
def test_python_files_are_at_most_250_lines() -> None:
root = Path(__file__).parents[1]
files = [*root.glob("src/**/*.py"), *root.glob("tests/**/*.py")]
oversized = {
str(path.relative_to(root)): len(path.read_text().splitlines())
for path in files
if len(path.read_text().splitlines()) > 250
}
assert oversized == {}
+98 -2
View File
@@ -1,10 +1,106 @@
import json
import logging import logging
import sys
from collections.abc import Iterator
from datetime import UTC, datetime
from agentci.logging import configure_logging import pytest
from agentci.logging import JsonFormatter, configure_logging
def test_suppresses_http_client_request_logs() -> None: @pytest.fixture(autouse=True)
def restore_logging_state() -> Iterator[None]:
root = logging.getLogger()
original_handlers = root.handlers[:]
original_level = root.level
client_levels = {name: logging.getLogger(name).level for name in ("httpx", "httpcore")}
try:
yield
finally:
root.handlers[:] = original_handlers
root.setLevel(original_level)
for name, level in client_levels.items():
logging.getLogger(name).setLevel(level)
def record(*, message: str = "processed job") -> logging.LogRecord:
return logging.LogRecord(
name="agentci.test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg=message,
args=(),
exc_info=None,
)
def test_json_formatter_emits_stable_structured_contract() -> None:
value = record()
value.__dict__.update(
operation="worker.execute",
job_id="job-1",
duration_ms=0,
unapproved_secret="not-for-output",
)
payload = json.loads(JsonFormatter().format(value))
assert payload == {
"timestamp": payload["timestamp"],
"level": "INFO",
"logger": "agentci.test",
"message": "processed job",
"operation": "worker.execute",
"job_id": "job-1",
"duration_ms": 0,
}
timestamp = datetime.fromisoformat(payload["timestamp"])
assert timestamp.tzinfo == UTC
def test_json_formatter_includes_exception_traceback() -> None:
try:
raise RuntimeError("provider unavailable")
except RuntimeError:
value = record(message="request failed")
value.exc_info = sys.exc_info()
payload = json.loads(JsonFormatter().format(value))
assert payload["message"] == "request failed"
assert "RuntimeError: provider unavailable" in payload["exception"]
def test_json_formatter_survives_unserializable_context() -> None:
value = record()
value.__dict__["operation"] = object()
payload = json.loads(JsonFormatter().format(value))
assert payload["level"] == "INFO"
assert payload["logger"] == "agentci.test"
assert payload["message"] == "Log record could not be serialized"
assert "TypeError" in payload["exception"]
def test_repeated_configuration_replaces_handlers_without_duplicate_output(
capsys: pytest.CaptureFixture[str],
) -> None:
configure_logging()
configure_logging() configure_logging()
root = logging.getLogger()
assert root.level == logging.INFO
assert len(root.handlers) == 1
assert isinstance(root.handlers[0].formatter, JsonFormatter)
assert logging.getLogger("httpx").level == logging.WARNING assert logging.getLogger("httpx").level == logging.WARNING
assert logging.getLogger("httpcore").level == logging.WARNING assert logging.getLogger("httpcore").level == logging.WARNING
logging.getLogger("agentci.contract").info(
"configured", extra={"operation": "logging.configure"}
)
lines = capsys.readouterr().err.splitlines()
assert len(lines) == 1
assert json.loads(lines[0])["operation"] == "logging.configure"
+294 -15
View File
@@ -5,8 +5,8 @@ from pathlib import Path
import httpx import httpx
import pytest import pytest
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError from agentci.opencode import OpenCode, OpenCodeError
from agentci.domain.models import AgentResult from agentci.workflows.model import AgentResult
API_DOCUMENT = { API_DOCUMENT = {
"paths": { "paths": {
@@ -42,9 +42,9 @@ class FakeCodeGraph:
self.prepared.append(workspace) self.prepared.append(workspace)
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCodeClient: def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCode:
selected_codegraph = codegraph or FakeCodeGraph() selected_codegraph = codegraph or FakeCodeGraph()
return OpenCodeClient( return OpenCode(
base_url="http://opencode:4096", base_url="http://opencode:4096",
username="opencode", username="opencode",
password="server-secret", password="server-secret",
@@ -74,20 +74,154 @@ async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Pa
await value.close() await value.close()
async def test_ready_rejects_missing_provider(tmp_path: Path) -> None: async def test_concurrent_readiness_checks_share_one_probe(tmp_path: Path) -> None:
health_started = asyncio.Event()
release_health = asyncio.Event()
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response: async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path == "/global/health": if request.url.path == "/global/health":
health_started.set()
await release_health.wait()
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"}) return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
if request.url.path == "/doc": if request.url.path == "/doc":
return httpx.Response(200, json=API_DOCUMENT) return httpx.Response(200, json=API_DOCUMENT)
return httpx.Response(200, json={**PROVIDERS, "connected": ["anthropic"]}) return httpx.Response(200, json=PROVIDERS)
value = client(tmp_path, handler)
first = asyncio.create_task(value.ready())
await health_started.wait()
second = asyncio.create_task(value.ready())
release_health.set()
assert await asyncio.gather(first, second) == [True, True]
assert paths == ["/global/health", "/doc", "/provider"]
await value.close()
@pytest.mark.parametrize(
("health", "document", "providers", "expected_paths"),
[
(
{"healthy": False, "version": "1.18.4"},
API_DOCUMENT,
PROVIDERS,
["/global/health"],
),
(
{"healthy": True, "version": "2.0.0"},
API_DOCUMENT,
PROVIDERS,
["/global/health"],
),
(
{"healthy": True, "version": "1.18.4"},
{"paths": {}},
PROVIDERS,
["/global/health", "/doc"],
),
(
{"healthy": True, "version": "1.18.4"},
API_DOCUMENT,
{**PROVIDERS, "connected": ["anthropic"]},
["/global/health", "/doc", "/provider"],
),
(
{"healthy": True, "version": "1.18.4"},
API_DOCUMENT,
{
"connected": ["openai"],
"all": [
{
"id": "openai",
"models": {
"model": {
"status": "active",
"capabilities": {"toolcall": False},
"variants": {"high": {}},
}
},
}
],
},
["/global/health", "/doc", "/provider"],
),
],
)
async def test_ready_rejects_incomplete_runtime_matrix(
tmp_path: Path,
health: object,
document: object,
providers: object,
expected_paths: list[str],
) -> None:
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path == "/global/health":
return httpx.Response(200, json=health)
if request.url.path == "/doc":
return httpx.Response(200, json=document)
return httpx.Response(200, json=providers)
value = client(tmp_path, handler)
assert not await value.ready()
assert paths == expected_paths
await value.close()
@pytest.mark.parametrize("response_kind", ["http_error", "invalid_json"])
async def test_ready_converts_probe_errors_to_not_ready(tmp_path: Path, response_kind: str) -> None:
async def handler(_request: httpx.Request) -> httpx.Response:
if response_kind == "http_error":
return httpx.Response(503)
return httpx.Response(200, content=b"not-json")
value = client(tmp_path, handler) value = client(tmp_path, handler)
assert not await value.ready() assert not await value.ready()
await value.close() await value.close()
async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None: @pytest.mark.parametrize(
("response_kind", "message"),
[
("invalid_json", "OpenCode request failed: POST /session"),
("array", "OpenCode returned an invalid response for POST /session"),
("http_error", "OpenCode request failed: POST /session: gateway detail"),
],
)
async def test_create_session_reports_malformed_and_error_responses(
tmp_path: Path, response_kind: str, message: str
) -> None:
async def handler(_request: httpx.Request) -> httpx.Response:
if response_kind == "invalid_json":
return httpx.Response(200, content=b"not-json")
if response_kind == "array":
return httpx.Response(200, json=[])
return httpx.Response(502, text="gateway detail")
value = client(tmp_path, handler)
with pytest.raises(OpenCodeError, match=message):
await value.create_session(tmp_path, "agent_result.json")
await value.close()
@pytest.mark.parametrize("payload", [{}, {"id": ""}, {"id": 42}])
async def test_create_session_requires_nonempty_string_id(
tmp_path: Path, payload: dict[str, object]
) -> None:
async def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
value = client(tmp_path, handler)
with pytest.raises(OpenCodeError, match="did not return a session ID"):
await value.create_session(tmp_path, "agent_result.json")
await value.close()
async def test_creates_and_resumes_structured_session_in_workspace(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
codegraph = FakeCodeGraph() codegraph = FakeCodeGraph()
@@ -117,7 +251,9 @@ async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
) )
value = client(tmp_path, handler, codegraph) value = client(tmp_path, handler, codegraph)
session_id, result = await value.start( session_id = await value.create_session(workspace, "agent_result.json")
result = await value.resume(
session_id=session_id,
workspace=workspace, workspace=workspace,
prompt="implement", prompt="implement",
model="openai/model", model="openai/model",
@@ -145,9 +281,7 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
body = json.loads(request.content) body = json.loads(request.content)
prompts.append(body["parts"][0]["text"]) prompts.append(body["parts"][0]["text"])
if len(prompts) == 1: if len(prompts) == 1:
return httpx.Response( return httpx.Response(200, json={"info": {"error": {"name": "StructuredOutputError"}}})
200, json={"info": {"error": {"name": "StructuredOutputError"}}}
)
return httpx.Response( return httpx.Response(
200, 200,
json={ json={
@@ -173,15 +307,160 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
await value.close() await value.close()
async def test_structured_validation_retry_exhaustion_reports_final_error(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
prompts: list[str] = []
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path.endswith("/abort"):
return httpx.Response(200, json=True)
body = json.loads(request.content)
prompts.append(body["parts"][0]["text"])
return httpx.Response(
200,
json={
"info": {"structured": {"summary_markdown": "", "tests": "invalid"}},
"parts": [],
},
)
value = client(tmp_path, handler)
with pytest.raises(
OpenCodeError,
match="structured result failed validation",
):
await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert len(prompts) == 2
assert prompts[0] == "continue"
assert "without repeating repository work" in prompts[1]
assert paths[-1] == "/session/ses_existing/abort"
await value.close()
async def test_cleanup_failure_does_not_mask_turn_error(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path.endswith("/abort"):
return httpx.Response(500, text="abort failed")
return httpx.Response(200, json={"unexpected": True})
value = client(tmp_path, handler)
with pytest.raises(
OpenCodeError,
match="OpenCode response did not include assistant metadata",
):
await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert paths == [
"/session/ses_existing/message",
"/session/ses_existing/abort",
]
await value.close()
async def test_close_aborts_an_active_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
message_started = asyncio.Event()
release_message = asyncio.Event()
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if request.url.path.endswith("/abort"):
assert request.headers["x-opencode-directory"] == str(workspace.resolve())
return httpx.Response(200, json=True)
message_started.set()
await release_message.wait()
return httpx.Response(
200,
json={
"info": {"structured": {"summary_markdown": "finished", "tests": []}},
"parts": [],
},
)
value = client(tmp_path, handler)
turn = asyncio.create_task(
value.resume(
session_id="ses_active",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
)
await message_started.wait()
await value.close()
release_message.set()
result = await turn
assert result.summary_markdown == "finished"
assert paths == ["/session/ses_active/message", "/session/ses_active/abort"]
assert value.client.is_closed
async def test_close_continues_after_active_session_abort_failure(tmp_path: Path) -> None:
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
if "/failed/" in request.url.path:
return httpx.Response(503)
return httpx.Response(200, json=True)
value = client(tmp_path, handler)
value._active_sessions.update(
{
"failed": tmp_path / "first",
"succeeds": tmp_path / "second",
}
)
await value.close()
assert paths == ["/session/failed/abort", "/session/succeeds/abort"]
assert value.client.is_closed
async def test_aborts_timed_out_session(tmp_path: Path) -> None: async def test_aborts_timed_out_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
aborted = False abort_count = 0
async def handler(request: httpx.Request) -> httpx.Response: async def handler(request: httpx.Request) -> httpx.Response:
nonlocal aborted nonlocal abort_count
if request.url.path.endswith("/abort"): if request.url.path.endswith("/abort"):
aborted = True abort_count += 1
return httpx.Response(200, json=True) return httpx.Response(200, json=True)
raise httpx.ReadTimeout("slow", request=request) raise httpx.ReadTimeout("slow", request=request)
@@ -197,7 +476,7 @@ async def test_aborts_timed_out_session(tmp_path: Path) -> None:
result_type=AgentResult, result_type=AgentResult,
) )
assert aborted assert abort_count == 1
await value.close() await value.close()
+50 -3
View File
@@ -3,11 +3,11 @@ from pathlib import Path
import httpx import httpx
import pytest import pytest
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError from agentci.opencode import OpenCode, OpenCodeError
def client(tmp_path: Path, status: int) -> OpenCodeClient: def client(tmp_path: Path, status: int) -> OpenCode:
return OpenCodeClient( return OpenCode(
base_url="http://opencode:4096", base_url="http://opencode:4096",
username="opencode", username="opencode",
password="secret", password="secret",
@@ -32,3 +32,50 @@ async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -
with pytest.raises(OpenCodeError): with pytest.raises(OpenCodeError):
await value.abort("session", tmp_path) await value.abort("session", tmp_path)
await value.close() await value.close()
async def test_abort_sends_authenticated_workspace_request(tmp_path: Path) -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(204)
value = OpenCode(
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(handler),
)
await value.abort("ses_123", tmp_path)
await value.close()
assert len(requests) == 1
request = requests[0]
assert request.method == "POST"
assert request.url == httpx.URL("http://opencode:4096/session/ses_123/abort")
assert request.headers["authorization"].startswith("Basic ")
assert request.headers["x-opencode-directory"] == str(tmp_path)
async def test_best_effort_abort_suppresses_transport_failure(tmp_path: Path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
value = OpenCode(
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(handler),
)
await value.abort("session", tmp_path, best_effort=True)
await value.close()
+168 -19
View File
@@ -1,10 +1,81 @@
import json import json
import shutil
import subprocess
from pathlib import Path from pathlib import Path
import pytest
ROOT = Path(__file__).parents[1]
def _indent(line: str) -> int:
return len(line) - len(line.lstrip())
def _service(name: str) -> list[str]:
lines = (ROOT / "compose.yaml").read_text().splitlines()
marker = f" {name}:"
start = lines.index(marker) + 1
end = next(
(
index
for index in range(start, len(lines))
if lines[index].strip() and _indent(lines[index]) <= 2
),
len(lines),
)
return lines[start:end]
def _section(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
marker = f"{' ' * indent}{name}:"
start = lines.index(marker) + 1
end = next(
(
index
for index in range(start, len(lines))
if lines[index].strip() and _indent(lines[index]) <= indent
),
len(lines),
)
return lines[start:end]
def _value(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
return str(json.loads(value))
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
return value
def _mapping(lines: list[str], name: str, *, indent: int = 4) -> dict[str, str]:
entries = _section(lines, name, indent=indent)
result: dict[str, str] = {}
for line in entries:
if _indent(line) != indent + 2 or line.lstrip().startswith("-"):
continue
key, separator, value = line.strip().partition(":")
if separator:
result[key] = _value(value)
return result
def _sequence(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
entries = _section(lines, name, indent=indent)
prefix = f"{' ' * (indent + 2)}- "
return [_value(line.removeprefix(prefix)) for line in entries if line.startswith(prefix)]
def _scalar(lines: list[str], name: str, *, indent: int = 4) -> str:
prefix = f"{' ' * indent}{name}:"
line = next(line for line in lines if line.startswith(prefix))
return _value(line.removeprefix(prefix))
def test_config_preserves_builtin_permissions_and_restricts_research() -> None: def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
root = Path(__file__).parents[1] config = json.loads((ROOT / "opencode" / "opencode.json").read_text())
config = json.loads((root / "opencode" / "opencode.json").read_text())
assert "permission" not in config assert "permission" not in config
assert all(name not in config["agent"] for name in ("build", "plan", "general")) assert all(name not in config["agent"] for name in ("build", "plan", "general"))
@@ -22,22 +93,100 @@ def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}" assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
def test_compose_removes_codex_sandbox_exceptions() -> None: def test_compose_services_have_expected_runtime_contract() -> None:
root = Path(__file__).parents[1] agentci = _service("agentci")
compose = (root / "compose.yaml").read_text() opencode = _service("opencode")
dockerfile = (root / "Dockerfile").read_text() agentci_environment = _mapping(agentci, "environment")
opencode_environment = _mapping(opencode, "environment")
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"): assert agentci_environment["AGENTCI_OPENCODE_URL"] == "http://opencode:4096"
assert agentci_environment["AGENTCI_EXPLORE_VARIANT"] == ("${AGENTCI_EXPLORE_VARIANT:-low}")
assert agentci_environment["AGENTCI_RESEARCH_VARIANT"] == ("${AGENTCI_RESEARCH_VARIANT:-high}")
assert opencode_environment["HOME"] == "/etc/opencode/home"
assert opencode_environment["OPENCODE_DISABLE_EXTERNAL_SKILLS"] == "1"
assert opencode_environment["OPENCODE_ENABLE_EXA"] == "1"
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in opencode_environment
assert _sequence(agentci, "volumes") == [
"agentci_data:/var/lib/agentci",
"./install-scripts:/etc/agentci/install-scripts:ro",
]
assert _sequence(opencode, "volumes") == [
"agentci_data:/var/lib/agentci",
"opencode_home:/var/lib/opencode",
]
assert _sequence(agentci, "tmpfs") == ["/run/agentci:mode=1777"]
assert _sequence(opencode, "tmpfs") == ["/run/agentci:mode=1777"]
assert json.loads(_scalar(opencode, "command")) == [
"opencode",
"serve",
"--hostname",
"0.0.0.0",
"--port",
"4096",
]
build = _section(agentci, "build")
assert _mapping(build, "args", indent=6)["AGENTCI_OPENCODE_VERSION"] == (
"${AGENTCI_OPENCODE_VERSION:-^1}"
)
assert all("no_cache" not in line for line in build)
dependency = _section(_section(agentci, "depends_on"), "opencode", indent=6)
assert _mapping([" dependency:", *dependency], "dependency", indent=6) == {
"condition": "service_healthy"
}
healthcheck = _mapping(opencode, "healthcheck")
health_command = json.loads(healthcheck["test"])
assert health_command[0] == "CMD-SHELL"
assert "/global/health" in health_command[1]
assert "OPENCODE_SERVER_PASSWORD_FILE" in health_command[1]
def test_compose_has_no_sandbox_security_exceptions() -> None:
compose = (ROOT / "compose.yaml").read_text()
for forbidden in (
"cap_add:",
"security_opt:",
"privileged:",
"seccomp=unconfined",
"apparmor=unconfined",
"bubblewrap",
):
assert forbidden not in compose assert forbidden not in compose
assert "no_cache" not in compose
assert "opencode_home:/var/lib/opencode" in compose
assert "HOME: /etc/opencode/home" in compose def test_container_pins_opencode_major_version_contract() -> None:
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()]
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")}
assert 'OPENCODE_ENABLE_EXA: "1"' in compose
assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments
assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose assert any(
assert compose.count("/run/agentci:mode=1777") == 2 line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines
assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose )
assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile
assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile
def test_compose_document_validates_when_compose_cli_is_available() -> None:
docker = shutil.which("docker")
if docker is None:
pytest.skip("Docker Compose CLI is not installed")
probe = subprocess.run(
[docker, "compose", "version"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
if probe.returncode:
pytest.skip("Docker Compose provider is not available")
result = subprocess.run(
[docker, "compose", "config", "--quiet"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+128 -6
View File
@@ -1,4 +1,6 @@
from agentci.adapters.opencode_support import api_contract_ready, models_ready import pytest
from agentci.opencode import api_contract_ready, models_ready
def test_api_contract_requires_session_message_and_abort_routes() -> None: def test_api_contract_requires_session_message_and_abort_routes() -> None:
@@ -17,7 +19,124 @@ def test_api_contract_requires_session_message_and_abort_routes() -> None:
assert not api_contract_ready(valid) assert not api_contract_ready(valid)
def test_model_readiness_requires_tools_and_configured_variant() -> None: @pytest.mark.parametrize(
"document",
[
None,
[],
{},
{"paths": []},
{
"paths": {
"/global/health": None,
"/provider": {"get": {}},
"/session": {"post": {}},
}
},
{
"paths": {
"/global/health": {"get": {}},
"/provider": {"get": {}},
"/session": {"post": {}},
7: {"post": {}},
}
},
],
)
def test_api_contract_rejects_malformed_documents(document: object) -> None:
assert not api_contract_ready(document)
def provider_payload(
*,
connected: object = None,
status: str = "active",
toolcall: object = True,
variants: object = None,
) -> dict[str, object]:
selected_connected = ["openai"] if connected is None else connected
selected_variants = {"high": {}} if variants is None else variants
return {
"connected": selected_connected,
"all": [
{
"id": "openai",
"models": {
"model": {
"status": status,
"capabilities": {"toolcall": toolcall},
"variants": selected_variants,
}
},
}
],
}
@pytest.mark.parametrize(
("payload", "requirement", "expected"),
[
(provider_payload(), ("openai", "model", "high"), True),
(provider_payload(), ("openai", "model", None), True),
(provider_payload(connected=("anthropic",)), ("openai", "model", "high"), False),
(provider_payload(status="deprecated"), ("openai", "model", "high"), False),
(provider_payload(toolcall=False), ("openai", "model", "high"), False),
(provider_payload(variants={}), ("openai", "model", "high"), False),
(provider_payload(), ("openai", "missing", "high"), False),
],
)
def test_model_readiness_matrix(
payload: object, requirement: tuple[str, str, str | None], expected: bool
) -> None:
assert models_ready(payload, {requirement}) is expected
@pytest.mark.parametrize(
"payload",
[
None,
[],
{},
{"connected": None, "all": []},
{"connected": ["openai"], "all": None},
{"connected": ["openai"], "all": [{"id": [], "models": {}}]},
{
"connected": ["openai"],
"all": [
{
"id": "openai",
"models": {
"model": {
"status": "active",
"capabilities": None,
"variants": {"high": {}},
}
},
}
],
},
{
"connected": ["openai"],
"all": [
{
"id": "openai",
"models": {
"model": {
"status": "active",
"capabilities": {"toolcall": True},
"variants": None,
}
},
}
],
},
],
)
def test_model_readiness_rejects_malformed_payloads(payload: object) -> None:
assert not models_ready(payload, {("openai", "model", "high")})
def test_model_readiness_requires_all_configured_models() -> None:
payload = { payload = {
"connected": ["openai"], "connected": ["openai"],
"all": [ "all": [
@@ -34,7 +153,10 @@ def test_model_readiness_requires_tools_and_configured_variant() -> None:
], ],
} }
assert models_ready(payload, {("openai", "model", "high")}) assert not models_ready(
assert not models_ready(payload, {("openai", "model", "missing")}) payload,
payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False {
assert not models_ready(payload, {("openai", "model", "high")}) ("openai", "model", "high"),
("openai", "other", None),
},
)
+109
View File
@@ -0,0 +1,109 @@
from pathlib import Path
from string import Template
import pytest
from pydantic import BaseModel
import agentci.prompts
from agentci.opencode import load_schema
from agentci.prompts import PromptLibrary
from agentci.workflows.model import (
AgentResult,
DiscussionReply,
PlanArtifact,
ReviewFinding,
ReviewReport,
)
PROMPTS = {
"plan_initial": {"context", "request"},
"plan_review": {"context", "artifact"},
"plan_revision": {"artifact", "review"},
"discuss": {"artifact", "message"},
"plan_iterate": {"context", "artifact", "review", "message"},
"implement_initial": {
"context",
"artifact",
"request",
"development_environment",
},
"implementation_review": {"issue_context", "artifact", "pull_context"},
"implementation_revision": {"review", "development_environment"},
"implementation_iterate": {
"context",
"review",
"message",
"development_environment",
},
"fix": {"context", "message", "development_environment"},
}
SCHEMAS: dict[str, tuple[type[BaseModel], set[str]]] = {
"plan.json": (PlanArtifact, {"plan_markdown"}),
"discussion.json": (DiscussionReply, {"markdown"}),
"agent_result.json": (AgentResult, {"summary_markdown", "tests"}),
"review.json": (ReviewReport, {"summary", "findings"}),
}
def prompt_directory() -> Path:
module_file = agentci.prompts.__file__
assert module_file is not None
return Path(module_file).parent
@pytest.mark.parametrize(("name", "identifiers"), PROMPTS.items())
def test_referenced_prompt_loads_and_renders(name: str, identifiers: set[str]) -> None:
directory = prompt_directory()
source = (directory / f"{name}.md").read_text()
values = {identifier: f"<{identifier}-value>" for identifier in identifiers}
assert set(Template(source).get_identifiers()) == identifiers
rendered = PromptLibrary(directory).render(name, **values)
for value in values.values():
assert value in rendered
def test_prompt_resource_set_matches_workflow_references() -> None:
names = {path.stem for path in prompt_directory().glob("*.md")}
assert names == set(PROMPTS)
def test_prompt_render_rejects_missing_template_value() -> None:
with pytest.raises(KeyError, match="request"):
PromptLibrary(prompt_directory()).render("plan_initial", context="context")
@pytest.mark.parametrize(("name", "contract"), SCHEMAS.items())
def test_referenced_schema_matches_result_model(
name: str, contract: tuple[type[BaseModel], set[str]]
) -> None:
model, required = contract
schema = load_schema(prompt_directory() / "schemas", name)
assert schema["type"] == "object"
assert schema["additionalProperties"] is False
assert set(schema["properties"]) == set(model.model_fields)
assert set(schema["required"]) == required
def test_review_finding_schema_matches_model_and_severity_values() -> None:
schema = load_schema(prompt_directory() / "schemas", "review.json")
finding = schema["properties"]["findings"]["items"]
assert set(finding["properties"]) == set(ReviewFinding.model_fields)
assert set(finding["required"]) == set(ReviewFinding.model_fields)
assert finding["properties"]["severity"]["enum"] == [
"blocking",
"major",
"minor",
]
assert finding["additionalProperties"] is False
def test_schema_resource_set_matches_workflow_references() -> None:
schema_directory = prompt_directory() / "schemas"
names = {path.name for path in schema_directory.glob("*.json")}
assert names == set(SCHEMAS)
+414
View File
@@ -0,0 +1,414 @@
from dataclasses import FrozenInstanceError, replace
from pathlib import Path
import pytest
from agentci.engine.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import (
Job,
JobKind,
JobStatus,
QueueName,
TaskKind,
TaskRequest,
Workflow,
WorkflowKind,
)
from agentci.engine.reducer import InvalidTransition, Transition, reduce_job, render_job_comment
def received(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
return reduce_job(
None,
CommandReceived(
job_id="job",
delivery_id="delivery",
receive_sequence=1,
command_body=body,
target_key="org/repo:pr:8" if pr_number else "org/repo:issue:1",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=pr_number,
requester="alice",
comment_id=4,
),
).job
def queued(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
state = received(body, pr_number=pr_number)
return reduce_job(state, PermissionGranted(job_id=state.id)).job
def running(*, workflow_id: str | None = None) -> Job:
state = queued()
state = reduce_job(state, JobStarted(job_id=state.id)).job
return replace(state, workflow_id=workflow_id)
def task_order(transition: Transition) -> list[tuple[TaskKind, QueueName]]:
return [(task.kind, task.queue) for task in transition.tasks]
def test_command_received_preserves_input_and_requests_authorization() -> None:
transition = reduce_job(
None,
CommandReceived(
job_id="job-7",
delivery_id="delivery-7",
receive_sequence=7,
command_body="/agent fix race",
target_key="org/repo:pr:8",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=8,
requester="alice",
comment_id=4,
),
)
assert transition == Transition(
Job(
id="job-7",
target_key="org/repo:pr:8",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=8,
requester="alice",
comment_id=4,
delivery_id="delivery-7",
receive_sequence=7,
command_body="/agent fix race",
),
(TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL),),
)
@pytest.mark.parametrize(
("body", "pr_number", "expected_kind", "expected_message"),
[
("/agent plan write tests", None, JobKind.PLAN, "write tests"),
("/agent iterate refine", None, JobKind.ITERATE_PLAN, "refine"),
("/agent iterate address review", 8, JobKind.ITERATE_IMPLEMENT, "address review"),
],
)
def test_permission_granted_queues_the_resolved_command(
body: str,
pr_number: int | None,
expected_kind: JobKind,
expected_message: str,
) -> None:
transition = reduce_job(
received(body, pr_number=pr_number),
PermissionGranted(job_id="job"),
)
assert (
transition.job.status,
transition.job.stage,
transition.job.kind,
transition.job.message,
task_order(transition),
) == (
JobStatus.QUEUED,
"queued",
expected_kind,
expected_message,
[
(TaskKind.EXECUTE, QueueName.JOBS),
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
],
)
@pytest.mark.parametrize(
("event", "expected_error"),
[
(PermissionDenied(job_id="job"), "repository write permission is required"),
(PermissionGranted(job_id="job"), "Unknown agent command"),
],
)
def test_permission_rejection_is_terminal_and_reconciled(
event: PermissionDenied | PermissionGranted,
expected_error: str,
) -> None:
state = received("/agent nonsense") if isinstance(event, PermissionGranted) else received()
transition = reduce_job(state, event)
assert (
transition.job.status,
transition.job.stage,
expected_error in (transition.job.error or ""),
task_order(transition),
) == (
JobStatus.REJECTED,
"rejected",
True,
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
)
def test_start_moves_a_queued_job_to_running_and_reconciles() -> None:
transition = reduce_job(queued(), JobStarted(job_id="job"))
assert (
transition.job.status,
transition.job.stage,
task_order(transition),
) == (
JobStatus.RUNNING,
"starting",
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
)
@pytest.mark.parametrize(
("event", "changes"),
[
(JobProgress(job_id="job", stage="cloning"), {"stage": "cloning"}),
(
WorkflowCreated(
job_id="job",
workflow=Workflow(
id="created-workflow",
kind=WorkflowKind.PLAN,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=Path("/work/repo"),
base_sha="abc",
),
stage="planning",
),
{"workflow_id": "created-workflow", "stage": "planning"},
),
(
WorkflowLinked(job_id="job", workflow_id="linked-workflow", stage="discussing"),
{"workflow_id": "linked-workflow", "stage": "discussing"},
),
(
RuntimeSessionLinked(job_id="job", session_id="session-1"),
{"runtime_session_id": "session-1"},
),
],
)
def test_running_progress_and_links_update_only_reported_fields(
event: JobProgress | WorkflowCreated | WorkflowLinked | RuntimeSessionLinked,
changes: dict[str, object],
) -> None:
state = running()
transition = reduce_job(state, event)
assert transition == Transition(replace(state, **changes))
def test_completion_persists_the_result_and_reconciles() -> None:
transition = reduce_job(running(), JobCompleted(job_id="job", comment_body="# Result"))
assert (
transition.job.status,
transition.job.stage,
transition.job.comment_body,
task_order(transition),
) == (
JobStatus.SUCCEEDED,
"completed",
"# Result",
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
)
@pytest.mark.parametrize(
("event", "workflow_id", "expected_status", "expected_stage", "expected_error", "tasks"),
[
(
JobFailed(job_id="job", stage="testing", error="tests failed"),
None,
JobStatus.FAILED,
"testing",
"tests failed",
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
),
(
JobFailed(job_id="job", stage="testing", error="tests failed"),
"workflow",
JobStatus.FAILED,
"testing",
"tests failed",
[
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
],
),
(
JobRejected(job_id="job", reason="no plan exists"),
"workflow",
JobStatus.REJECTED,
"rejected",
"no plan exists",
[
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
],
),
],
)
def test_running_failure_and_rejection_are_terminal_with_ordered_cleanup(
event: JobFailed | JobRejected,
workflow_id: str | None,
expected_status: JobStatus,
expected_stage: str,
expected_error: str,
tasks: list[tuple[TaskKind, QueueName]],
) -> None:
transition = reduce_job(running(workflow_id=workflow_id), event)
assert (
transition.job.status,
transition.job.stage,
transition.job.error,
task_order(transition),
) == (expected_status, expected_stage, expected_error, tasks)
@pytest.mark.parametrize(
("workflow_id", "tasks"),
[
(
None,
[
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
],
),
(
"workflow",
[
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
],
),
],
)
def test_restart_fails_a_running_job_with_ordered_cleanup(
workflow_id: str | None,
tasks: list[tuple[TaskKind, QueueName]],
) -> None:
transition = reduce_job(running(workflow_id=workflow_id), ServiceRestarted(job_id="job"))
assert (
transition.job.status,
transition.job.stage,
transition.job.error,
task_order(transition),
) == (
JobStatus.FAILED,
"interrupted",
"Service restarted during an active OpenCode turn",
tasks,
)
def test_restart_is_a_noop_after_a_job_is_terminal() -> None:
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
assert reduce_job(completed, ServiceRestarted(job_id="job")) == Transition(completed)
def test_comment_link_is_allowed_after_a_job_is_terminal() -> None:
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
assert reduce_job(completed, CommentLinked(job_id="job", comment_id=9)).job == replace(
completed, accepted_comment_id=9
)
@pytest.mark.parametrize(
("state", "event"),
[
(received(), JobStarted(job_id="job")),
(queued(), PermissionGranted(job_id="job")),
(running(), PermissionGranted(job_id="job")),
(
reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job,
JobStarted(job_id="job"),
),
],
ids=["received", "queued", "running", "terminal"],
)
def test_events_invalid_for_the_current_status_are_rejected(
state: Job,
event: JobStarted | PermissionGranted,
) -> None:
with pytest.raises(InvalidTransition, match="invalid while job"):
reduce_job(state, event)
def test_only_command_received_can_create_state() -> None:
with pytest.raises(InvalidTransition, match="Only CommandReceived"):
reduce_job(None, PermissionGranted(job_id="job"))
def test_event_job_id_must_match_state() -> None:
with pytest.raises(InvalidTransition, match="job ID does not match"):
reduce_job(received(), PermissionDenied(job_id="another-job"))
@pytest.mark.parametrize(
("state", "expected"),
[
(
received(),
"<!-- agentci:job id=job -->\nAgent job `job` received (`command`; stage: `received`).",
),
(
queued(),
"<!-- agentci:job id=job -->\nAgent job `job` queued (`plan`; stage: `queued`).",
),
(
replace(
running(),
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body="# Done",
),
"<!-- agentci:job id=job -->\n# Done",
),
(
replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"),
"<!-- agentci:job id=job -->\nAgent job `job` was rejected: not allowed",
),
(
replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"),
"<!-- agentci:job id=job -->\nAgent job `job` failed during `testing`: failed",
),
],
ids=["received", "queued", "succeeded", "rejected", "failed"],
)
def test_render_comment_describes_each_job_outcome(state: Job, expected: str) -> None:
assert render_job_comment(state) == expected
def test_job_state_is_immutable() -> None:
state = received()
with pytest.raises(FrozenInstanceError):
state.stage = "changed" # type: ignore[misc]
+462
View File
@@ -0,0 +1,462 @@
import sqlite3
from contextlib import closing
from dataclasses import replace
from pathlib import Path
from typing import cast
import pytest
from agentci.engine import _sqlite
from agentci.engine.events import (
CommentLinked,
JobCompleted,
JobStarted,
PermissionGranted,
WorkflowCreated,
)
from agentci.engine.model import (
IncomingCommand,
Job,
QueueName,
TaskKind,
TaskRequest,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import Transition
from agentci.engine.repository import Repository
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
class TrackingConnection:
def __init__(self) -> None:
self.closed = False
def __enter__(self) -> "TrackingConnection":
return self
def __exit__(self, *_args: object) -> None:
return None
def close(self) -> None:
self.closed = True
class FailingSetupConnection:
def __init__(self) -> None:
self.closed = False
self.row_factory: object | None = None
def execute(self, _statement: str) -> None:
raise sqlite3.OperationalError("pragma failed")
def close(self) -> None:
self.closed = True
@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,
body: str = "/agent plan",
*,
issue: int = 3,
pr: int | None = None,
) -> IncomingCommand:
return IncomingCommand(
delivery_id=delivery,
comment_id=int(delivery.rsplit("-", 1)[-1]),
repo_owner="alice",
repo_name="repo",
issue_number=issue,
pr_number=pr,
requester="alice",
body=body,
)
async def create_running_job(repository: Repository, delivery: str, *, issue: int = 3) -> Job:
job = (await repository.accept(command(delivery, issue=issue))).job
job = (await repository.apply(f"{delivery}:grant", PermissionGranted(job_id=job.id))).job
return (await repository.apply(f"{delivery}:start", JobStarted(job_id=job.id))).job
async def test_repository_closes_connections_after_success_and_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
connections = [TrackingConnection(), TrackingConnection()]
monkeypatch.setattr(
_sqlite,
"connect",
lambda _path: cast(sqlite3.Connection, connections.pop(0)),
)
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
successful = cast(TrackingConnection, await repository._run(lambda connection: connection))
def fail(_connection: sqlite3.Connection) -> None:
raise RuntimeError("operation failed")
failing = connections[0]
with pytest.raises(RuntimeError, match="operation failed"):
await repository._run(fail)
assert successful.closed
assert failing.closed
def test_sqlite_setup_closes_connection_after_pragma_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = FailingSetupConnection()
monkeypatch.setattr(
_sqlite.sqlite3,
"connect",
lambda *_args, **_kwargs: cast(sqlite3.Connection, connection),
)
with pytest.raises(sqlite3.OperationalError, match="pragma failed"):
_sqlite.connect(tmp_path / "state.sqlite3")
assert connection.closed
def build_workflow(
workflow_id: str,
workspace: Path,
*,
kind: WorkflowKind = WorkflowKind.PLAN,
owner: str = "alice",
repo: str = "repo",
issue: int = 3,
pr: int | None = None,
status: WorkflowStatus = WorkflowStatus.ACTIVE,
) -> Workflow:
return Workflow(
id=workflow_id,
kind=kind,
repo_owner=owner,
repo_name=repo,
issue_number=issue,
pr_number=pr,
workspace_path=workspace / workflow_id,
base_sha=f"base-{workflow_id}",
status=status,
)
async def attach_workflow(
repository: Repository,
workspace: Path,
*,
delivery: str,
workflow_id: str,
status: WorkflowStatus = WorkflowStatus.ACTIVE,
) -> tuple[Job, Workflow]:
job = await create_running_job(repository, delivery)
workflow = build_workflow(workflow_id, workspace, status=status)
result = await repository.apply(
f"{workflow_id}:created",
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
)
return result.job, workflow
def insert_workflow(
connection: sqlite3.Connection,
workflow: Workflow,
created_at: str,
) -> None:
connection.execute(
"""INSERT INTO workflows(
id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch,
workspace_path, primary_session_id, reviewer_session_id, artifact, review_json,
status, runtime, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
workflow.id,
workflow.kind.value,
workflow.repo_owner,
workflow.repo_name,
workflow.issue_number,
workflow.pr_number,
workflow.base_sha,
workflow.branch,
str(workflow.workspace_path),
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
workflow.runtime,
created_at,
created_at,
),
)
async def test_receive_is_idempotent_without_consuming_sequence(
repository: Repository,
) -> None:
first = await repository.accept(command("delivery-1"))
duplicate = await repository.accept(command("delivery-1"))
second = await repository.accept(command("delivery-2"))
assert (
first.duplicate,
duplicate.duplicate,
duplicate.job.id,
second.job.receive_sequence,
) == (False, True, first.job.id, first.job.receive_sequence + 1)
async def test_repository_stamps_start_and_completion_once(
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
job = (await repository.apply("grant", PermissionGranted(job_id=job.id))).job
clock["now"] = "2026-02-01T00:01:00+00:00"
job = (await repository.apply("start", JobStarted(job_id=job.id))).job
clock["now"] = "2026-02-01T00:02:00+00:00"
job = (await repository.apply("complete", JobCompleted(job_id=job.id, comment_body="done"))).job
clock["now"] = "2026-02-01T00:03:00+00:00"
await repository.apply("comment", CommentLinked(job_id=job.id, comment_id=99))
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
timestamps = connection.execute(
"SELECT started_at, finished_at FROM jobs WHERE id=?", (job.id,)
).fetchone()
assert timestamps == (
"2026-02-01T00:01:00+00:00",
"2026-02-01T00:02:00+00:00",
)
async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_failure(
repository: Repository,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
job = await create_running_job(repository, "delivery-1")
workflow = build_workflow("workflow-rollback", tmp_path)
original_insert_tasks = _sqlite.insert_tasks
def fail_after_task_write(
connection: sqlite3.Connection,
event_id: str,
transition: Transition,
timestamp: str,
) -> None:
forced_task = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
original_insert_tasks(
connection,
event_id,
replace(transition, tasks=(forced_task,)),
timestamp,
)
raise RuntimeError("injected task persistence failure")
monkeypatch.setattr(_sqlite, "insert_tasks", fail_after_task_write)
with pytest.raises(RuntimeError, match="injected task persistence failure"):
await repository.apply(
"workflow-rollback:created",
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
)
persisted_job = await repository.get_job(job.id)
persisted_workflow = await repository.get_workflow(workflow.id)
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
event_count = connection.execute(
"SELECT COUNT(*) FROM job_events WHERE event_id=?",
("workflow-rollback:created",),
).fetchone()
task_count = connection.execute(
"SELECT COUNT(*) FROM listener_tasks WHERE source_event_id=?",
("workflow-rollback:created",),
).fetchone()
assert (persisted_job, persisted_workflow, event_count, task_count) == (
job,
None,
(0,),
(0,),
)
async def test_duplicate_event_application_does_not_duplicate_tasks(
repository: Repository,
) -> None:
job = (await repository.accept(command("delivery-1"))).job
applied = await repository.apply("permission", PermissionGranted(job_id=job.id))
duplicate = await repository.apply("permission", PermissionGranted(job_id=job.id))
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
tasks = connection.execute(
"""SELECT ordinal, listener, queue FROM listener_tasks
WHERE source_event_id=? ORDER BY ordinal""",
("permission",),
).fetchall()
assert (duplicate.duplicate, duplicate.job, tasks) == (
True,
applied.job,
[(0, "execute", "jobs"), (1, "reconcile_comment", "control")],
)
async def test_workflow_creation_and_job_link_commit_together(
repository: Repository,
tmp_path: Path,
) -> None:
job, workflow = await attach_workflow(
repository,
tmp_path,
delivery="delivery-1",
workflow_id="workflow-atomic",
)
assert (job.workflow_id, await repository.get_workflow(workflow.id)) == (
workflow.id,
workflow,
)
async def test_save_workflow_updates_the_mutable_snapshot_and_timestamp(
repository: Repository,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_, workflow = await attach_workflow(
repository,
tmp_path,
delivery="delivery-1",
workflow_id="workflow-update",
)
updated = replace(
workflow,
pr_number=17,
branch="agent/updated",
primary_session_id="primary",
reviewer_session_id="reviewer",
artifact="# Updated plan",
review_json='{"verdict":"approved"}',
status=WorkflowStatus.COMPLETED,
)
monkeypatch.setattr(_sqlite, "now", lambda: "2026-02-02T00:00:00+00:00")
await repository.save_workflow(updated)
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
updated_at = connection.execute(
"SELECT updated_at FROM workflows WHERE id=?", (workflow.id,)
).fetchone()
assert (await repository.get_workflow(workflow.id), updated_at) == (
updated,
("2026-02-02T00:00:00+00:00",),
)
async def test_saving_unknown_workflow_fails(repository: Repository, tmp_path: Path) -> None:
workflow = build_workflow("missing", tmp_path)
with pytest.raises(KeyError, match="Unknown workflow"):
await repository.save_workflow(workflow)
async def test_fail_job_workflow_fails_only_active_workflows(
repository: Repository,
tmp_path: Path,
) -> None:
active_job, active = await attach_workflow(
repository,
tmp_path,
delivery="delivery-1",
workflow_id="active-workflow",
)
completed_job, completed = await attach_workflow(
repository,
tmp_path,
delivery="delivery-2",
workflow_id="completed-workflow",
status=WorkflowStatus.COMPLETED,
)
await repository.fail_job_workflow(active_job.id)
await repository.fail_job_workflow(completed_job.id)
assert (
(await repository.get_workflow(active.id)).status, # type: ignore[union-attr]
(await repository.get_workflow(completed.id)).status, # type: ignore[union-attr]
) == (WorkflowStatus.FAILED, WorkflowStatus.COMPLETED)
async def test_latest_workflow_returns_newest_completed_match(
repository: Repository,
tmp_path: Path,
) -> None:
older = build_workflow("plan-older", tmp_path, status=WorkflowStatus.COMPLETED)
newest = build_workflow("plan-newest", tmp_path, status=WorkflowStatus.COMPLETED)
active = build_workflow("plan-active", tmp_path)
wrong_issue = build_workflow(
"plan-wrong-issue", tmp_path, issue=4, status=WorkflowStatus.COMPLETED
)
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
insert_workflow(connection, active, "2026-01-03T00:00:00+00:00")
insert_workflow(connection, wrong_issue, "2026-01-04T00:00:00+00:00")
assert await repository.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) == newest
async def test_workflow_for_pr_returns_newest_implementation_match(
repository: Repository,
tmp_path: Path,
) -> None:
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
plan = build_workflow("plan-same-pr", tmp_path, pr=17)
wrong_repo = build_workflow(
"implementation-wrong-repo",
tmp_path,
kind=WorkflowKind.IMPLEMENT,
repo="another",
pr=17,
)
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
insert_workflow(connection, plan, "2026-01-03T00:00:00+00:00")
insert_workflow(connection, wrong_repo, "2026-01-04T00:00:00+00:00")
assert await repository.workflow_for_pr("alice", "repo", 17) == newest
async def test_implementation_workflows_are_newest_first_and_require_a_pr(
repository: Repository,
tmp_path: Path,
) -> None:
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=18)
no_pr = build_workflow("implementation-no-pr", tmp_path, kind=WorkflowKind.IMPLEMENT)
plan = build_workflow("plan-with-pr", tmp_path, pr=19)
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
insert_workflow(connection, no_pr, "2026-01-03T00:00:00+00:00")
insert_workflow(connection, plan, "2026-01-04T00:00:00+00:00")
assert await repository.implementation_workflows("alice", "repo", 3) == [newest, older]
+123
View File
@@ -0,0 +1,123 @@
import asyncio
import sqlite3
from contextlib import closing
from pathlib import Path
import pytest
from agentci.engine.events import PermissionDenied, PermissionGranted
from agentci.engine.model import IncomingCommand, QueueName, 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",
)
async def test_concurrent_duplicate_accepts_create_one_job_and_event(
repository: Repository,
) -> None:
results = await asyncio.gather(
repository.accept(command("delivery-1")),
repository.accept(command("delivery-1")),
)
assert sorted(result.duplicate for result in results) == [False, True]
assert len({result.job.id for result in results}) == 1
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
job_count = connection.execute("SELECT COUNT(*) FROM jobs").fetchone()
event_count = connection.execute("SELECT COUNT(*) FROM job_events").fetchone()
assert job_count == (1,)
assert event_count == (1,)
second = await repository.accept(command("delivery-2"))
assert second.job.receive_sequence == results[0].job.receive_sequence + 1
async def test_received_job_blocks_later_execute_task_for_same_target(
repository: Repository,
) -> None:
first = (await repository.accept(command("delivery-1"))).job
second = (await repository.accept(command("delivery-2"))).job
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
assert await repository.claim_task(QueueName.JOBS) is None
await repository.apply("deny-1", PermissionDenied(job_id=first.id))
task = await repository.claim_task(QueueName.JOBS)
assert task is not None
assert task.job_id == second.id
assert task.kind is TaskKind.EXECUTE
async def test_received_job_does_not_block_a_different_target(
repository: Repository,
) -> None:
await repository.accept(command("delivery-1", issue=3))
second = (await repository.accept(command("delivery-2", issue=4))).job
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
task = await repository.claim_task(QueueName.JOBS)
assert task is not None
assert task.job_id == second.id
async def test_claims_multiple_eligible_targets_without_duplicates(
repository: Repository,
) -> None:
first = (await repository.accept(command("delivery-1", issue=3))).job
second = (await repository.accept(command("delivery-2", issue=4))).job
await repository.apply("grant-1", PermissionGranted(job_id=first.id))
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
claimed = [
await repository.claim_task(QueueName.JOBS),
await repository.claim_task(QueueName.JOBS),
]
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id]
assert await repository.claim_task(QueueName.JOBS) is None
async def test_concurrent_claims_do_not_duplicate_task(repository: Repository) -> None:
await repository.accept(command("delivery-1"))
claims = await asyncio.gather(
repository.claim_task(QueueName.CONTROL),
repository.claim_task(QueueName.CONTROL),
)
claimed = [task for task in claims if task is not None]
assert len(claimed) == 1
assert claimed[0].kind is TaskKind.AUTHORIZE
assert claimed[0].queue is QueueName.CONTROL
assert await repository.claim_task(QueueName.CONTROL) is None
async def test_duplicate_event_id_cannot_be_reused_for_another_job(
repository: Repository,
) -> None:
first = (await repository.accept(command("delivery-1", issue=3))).job
second = (await repository.accept(command("delivery-2", issue=4))).job
await repository.apply("permission", PermissionGranted(job_id=first.id))
with pytest.raises(RuntimeError):
await repository.apply("permission", PermissionGranted(job_id=second.id))
+413
View File
@@ -0,0 +1,413 @@
import sqlite3
from contextlib import closing
from pathlib import Path
import pytest
from agentci.engine.model import JobKind, JobStatus, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.repository import Repository
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
PRE_V3_CREATED_AT = "2026-01-01T00:00:00+00:00"
def create_schema_v2_database(database_path: Path, workspace: Path) -> None:
with closing(sqlite3.connect(database_path)) as connection, connection:
connection.executescript((MIGRATIONS / "001_initial.sql").read_text())
connection.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(1, "2025-11-01T00:00:00+00:00"),
)
connection.executescript((MIGRATIONS / "002_opencode_sessions.sql").read_text())
connection.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(2, "2025-12-01T00:00:00+00:00"),
)
connection.executemany(
"INSERT INTO deliveries(delivery_id, comment_id, received_at) VALUES (?, ?, ?)",
[
("delivery-queued", 102, "2025-12-31T23:59:58+00:00"),
("delivery-unrelated", 999, "2025-12-31T23:59:59+00:00"),
],
)
connection.executemany(
"""INSERT INTO workflows(
id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch,
workspace_path, primary_session_id, reviewer_session_id, artifact, review_json,
status, created_at, updated_at, runtime
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
"pre-v3-plan-workflow",
"plan",
"alice",
"repo",
7,
None,
"plan-base",
"agent/plan",
str(workspace / "plan"),
"plan-primary",
"plan-reviewer",
"# Existing plan",
'{"verdict":"approved"}',
"active",
"2025-12-30T00:00:00+00:00",
"2025-12-31T00:00:00+00:00",
"opencode",
),
(
"pre-v3-implementation-workflow",
"implement",
"alice",
"repo",
8,
44,
"implementation-base",
"agent/implementation",
str(workspace / "implementation"),
"implementation-primary",
"implementation-reviewer",
"# Existing implementation",
'{"verdict":"changes_requested"}',
"completed",
"2025-12-29T00:00:00+00:00",
"2025-12-31T12:00:00+00:00",
"codex",
),
],
)
connection.executemany(
"""INSERT INTO jobs(
id, kind, target_key, repo_owner, repo_name, issue_number, pr_number,
requester, message, comment_id, workflow_id, status, stage, error,
accepted_comment_id, started_comment_id, created_at, started_at, finished_at,
runtime_session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
"pre-v3-job-a-terminal",
"iterate_implement",
"alice/repo:pr:44",
"alice",
"repo",
8,
44,
"bob",
"address review",
101,
"pre-v3-implementation-workflow",
"succeeded",
"completed",
None,
201,
202,
PRE_V3_CREATED_AT,
"2026-01-01T00:01:00+00:00",
"2026-01-01T00:02:00+00:00",
"implementation-runtime",
),
(
"pre-v3-job-b-queued",
"iterate_plan",
"alice/repo:issue:7",
"alice",
"repo",
7,
None,
"alice",
"refine plan",
102,
"pre-v3-plan-workflow",
"queued",
"queued",
None,
203,
204,
PRE_V3_CREATED_AT,
None,
None,
"plan-runtime",
),
],
)
@pytest.fixture
async def migrated_pre_v3_repository(tmp_path: Path) -> Repository:
database_path = tmp_path / "schema-v2.sqlite3"
create_schema_v2_database(database_path, tmp_path / "workspaces")
repository = Repository(database_path, MIGRATIONS)
await repository.initialize()
return repository
async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_commands(
migrated_pre_v3_repository: Repository,
) -> None:
with (
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
connection,
):
migrated = connection.execute(
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs "
"ORDER BY receive_sequence"
).fetchall()
assert migrated == [
(
"pre-v3-job-a-terminal",
"legacy:pre-v3-job-a-terminal",
1,
"/agent iterate address review",
),
("pre-v3-job-b-queued", "delivery-queued", 2, "/agent iterate refine plan"),
]
async def test_schema_v2_migration_preserves_pre_v3_job_fields(
migrated_pre_v3_repository: Repository,
) -> None:
terminal = await migrated_pre_v3_repository.get_job("pre-v3-job-a-terminal")
queued = await migrated_pre_v3_repository.get_job("pre-v3-job-b-queued")
with (
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
connection,
):
storage_fields = connection.execute(
"""SELECT id, started_comment_id, created_at, started_at, finished_at
FROM jobs ORDER BY receive_sequence"""
).fetchall()
assert (
terminal
and (
terminal.kind,
terminal.target_key,
terminal.repo_owner,
terminal.repo_name,
terminal.issue_number,
terminal.pr_number,
terminal.requester,
terminal.message,
terminal.comment_id,
terminal.workflow_id,
terminal.status,
terminal.stage,
terminal.error,
terminal.runtime_session_id,
terminal.accepted_comment_id,
terminal.comment_body,
),
queued
and (
queued.kind,
queued.target_key,
queued.requester,
queued.message,
queued.comment_id,
queued.workflow_id,
queued.status,
queued.stage,
queued.runtime_session_id,
queued.accepted_comment_id,
),
storage_fields,
) == (
(
JobKind.ITERATE_IMPLEMENT,
"alice/repo:pr:44",
"alice",
"repo",
8,
44,
"bob",
"address review",
101,
"pre-v3-implementation-workflow",
JobStatus.SUCCEEDED,
"completed",
None,
"implementation-runtime",
201,
None,
),
(
JobKind.ITERATE_PLAN,
"alice/repo:issue:7",
"alice",
"refine plan",
102,
"pre-v3-plan-workflow",
JobStatus.QUEUED,
"queued",
"plan-runtime",
203,
),
[
(
"pre-v3-job-a-terminal",
202,
PRE_V3_CREATED_AT,
"2026-01-01T00:01:00+00:00",
"2026-01-01T00:02:00+00:00",
),
("pre-v3-job-b-queued", 204, PRE_V3_CREATED_AT, None, None),
],
)
async def test_schema_v2_migration_preserves_pre_v3_workflows(
migrated_pre_v3_repository: Repository,
tmp_path: Path,
) -> None:
plan = await migrated_pre_v3_repository.get_workflow("pre-v3-plan-workflow")
implementation = await migrated_pre_v3_repository.get_workflow("pre-v3-implementation-workflow")
with (
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
connection,
):
timestamps = connection.execute(
"SELECT id, created_at, updated_at FROM workflows ORDER BY id"
).fetchall()
assert ((plan, implementation), timestamps) == (
(
Workflow(
id="pre-v3-plan-workflow",
kind=WorkflowKind.PLAN,
repo_owner="alice",
repo_name="repo",
issue_number=7,
workspace_path=tmp_path / "workspaces" / "plan",
base_sha="plan-base",
runtime="opencode",
branch="agent/plan",
primary_session_id="plan-primary",
reviewer_session_id="plan-reviewer",
artifact="# Existing plan",
review_json='{"verdict":"approved"}',
status=WorkflowStatus.ACTIVE,
),
Workflow(
id="pre-v3-implementation-workflow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="alice",
repo_name="repo",
issue_number=8,
pr_number=44,
workspace_path=tmp_path / "workspaces" / "implementation",
base_sha="implementation-base",
runtime="codex",
branch="agent/implementation",
primary_session_id="implementation-primary",
reviewer_session_id="implementation-reviewer",
artifact="# Existing implementation",
review_json='{"verdict":"changes_requested"}',
status=WorkflowStatus.COMPLETED,
),
),
[
(
"pre-v3-implementation-workflow",
"2025-12-29T00:00:00+00:00",
"2025-12-31T12:00:00+00:00",
),
(
"pre-v3-plan-workflow",
"2025-12-30T00:00:00+00:00",
"2025-12-31T00:00:00+00:00",
),
],
)
async def test_schema_v2_migration_creates_audit_events_and_only_queued_tasks(
migrated_pre_v3_repository: Repository,
) -> None:
with (
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
connection,
):
events = connection.execute(
"""SELECT event_id, job_id, event_type, payload_json, created_at
FROM job_events ORDER BY job_id"""
).fetchall()
tasks = connection.execute(
"""SELECT job_id, source_event_id, ordinal, listener, queue, status,
available_at, created_at
FROM listener_tasks ORDER BY ordinal"""
).fetchall()
assert (events, tasks) == (
[
(
"delivery:legacy:pre-v3-job-a-terminal",
"pre-v3-job-a-terminal",
"legacy",
"{}",
PRE_V3_CREATED_AT,
),
(
"delivery:delivery-queued",
"pre-v3-job-b-queued",
"legacy",
"{}",
PRE_V3_CREATED_AT,
),
],
[
(
"pre-v3-job-b-queued",
"delivery:delivery-queued",
0,
"execute",
"jobs",
"pending",
PRE_V3_CREATED_AT,
PRE_V3_CREATED_AT,
),
(
"pre-v3-job-b-queued",
"delivery:delivery-queued",
1,
"reconcile_comment",
"control",
"pending",
PRE_V3_CREATED_AT,
PRE_V3_CREATED_AT,
),
],
)
def durable_snapshot(database_path: Path) -> tuple[object, ...]:
with closing(sqlite3.connect(database_path)) as connection, connection:
return (
connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall(),
connection.execute(
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs ORDER BY id"
).fetchall(),
connection.execute(
"""SELECT event_id, job_id, event_type, payload_json
FROM job_events ORDER BY event_id"""
).fetchall(),
connection.execute(
"""SELECT job_id, source_event_id, ordinal, listener, queue, status
FROM listener_tasks ORDER BY id"""
).fetchall(),
)
async def test_reopening_migrated_schema_v2_database_is_idempotent(
migrated_pre_v3_repository: Repository,
) -> None:
before = durable_snapshot(migrated_pre_v3_repository.database_path)
reopened = Repository(migrated_pre_v3_repository.database_path, MIGRATIONS)
await reopened.initialize()
assert (before[0], durable_snapshot(reopened.database_path)) == (
[(1,), (2,), (3,)],
before,
)
+193
View File
@@ -0,0 +1,193 @@
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],
)
+178
View File
@@ -0,0 +1,178 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import agentci.runtime as runtime_module
from agentci.config import Settings
from agentci.runtime import Runtime
class ClosingClient:
def __init__(self, name: str, events: list[str], error: Exception | None = None) -> None:
self.name = name
self.events = events
self.error = error
async def close(self) -> None:
self.events.append(self.name)
if self.error is not None:
raise self.error
def runtime(opencode: object, gitea: object) -> Runtime:
return Runtime(
settings=SimpleNamespace(), # type: ignore[arg-type]
repository=SimpleNamespace(), # type: ignore[arg-type]
gitea=gitea, # type: ignore[arg-type]
git=SimpleNamespace(), # type: ignore[arg-type]
opencode=opencode, # type: ignore[arg-type]
worker=SimpleNamespace(), # type: ignore[arg-type]
)
async def test_close_releases_provider_clients_in_order() -> None:
events: list[str] = []
value = runtime(ClosingClient("opencode", events), ClosingClient("gitea", events))
await value.close()
assert events == ["opencode", "gitea"]
async def test_close_still_releases_gitea_when_opencode_close_fails() -> None:
events: list[str] = []
value = runtime(
ClosingClient("opencode", events, RuntimeError("opencode close failed")),
ClosingClient("gitea", events),
)
with pytest.raises(RuntimeError, match="opencode close failed"):
await value.close()
assert events == ["opencode", "gitea"]
async def test_close_propagates_gitea_close_failure_after_opencode_closes() -> None:
events: list[str] = []
value = runtime(
ClosingClient("opencode", events),
ClosingClient("gitea", events, RuntimeError("gitea close failed")),
)
with pytest.raises(RuntimeError, match="gitea close failed"):
await value.close()
assert events == ["opencode", "gitea"]
async def test_build_runtime_wires_components_without_starting_real_clients(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
token_file = tmp_path / "gitea-token"
password_file = tmp_path / "opencode-password"
token_file.write_text("token\n")
password_file.write_text("password\n")
settings = Settings(
_env_file=None, # type: ignore[call-arg]
data_dir=tmp_path / "data",
gitea_url="https://gitea.example/",
gitea_token_file=token_file,
opencode_url="https://opencode.example/",
opencode_server_password_file=password_file,
install_scripts=["python"],
max_concurrent_jobs=4,
)
initialize = AsyncMock()
repository = SimpleNamespace(initialize=initialize)
gitea = SimpleNamespace()
git = SimpleNamespace()
opencode = SimpleNamespace()
development = SimpleNamespace()
prompts = SimpleNamespace()
services = SimpleNamespace()
worker = SimpleNamespace()
repository_constructor = Mock(return_value=repository)
gitea_constructor = Mock(return_value=gitea)
git_constructor = Mock(return_value=git)
opencode_constructor = Mock(return_value=opencode)
development_constructor = Mock(return_value=development)
prompt_constructor = Mock(return_value=prompts)
services_constructor = Mock(return_value=services)
worker_constructor = Mock(return_value=worker)
monkeypatch.setattr(runtime_module, "Repository", repository_constructor)
monkeypatch.setattr(runtime_module, "Gitea", gitea_constructor)
monkeypatch.setattr(runtime_module, "Git", git_constructor)
monkeypatch.setattr(runtime_module, "OpenCode", opencode_constructor)
monkeypatch.setattr(runtime_module, "DevelopmentEnvironment", development_constructor)
monkeypatch.setattr(runtime_module, "PromptLibrary", prompt_constructor)
monkeypatch.setattr(runtime_module, "WorkflowServices", services_constructor)
monkeypatch.setattr(runtime_module, "Worker", worker_constructor)
built = await runtime_module.build_runtime(settings)
assert runtime_module.__file__ is not None
package_dir = Path(runtime_module.__file__).parent
repository_constructor.assert_called_once_with(
settings.database_path, package_dir / "migrations"
)
initialize.assert_awaited_once_with()
gitea_constructor.assert_called_once_with("https://gitea.example", "token")
git_constructor.assert_called_once_with(
gitea_url="https://gitea.example",
username=settings.bot_username,
token="token",
askpass_path=settings.askpass_path,
commit_name=settings.bot_name,
commit_email=settings.bot_email,
)
opencode_constructor.assert_called_once_with(
base_url="https://opencode.example",
username=settings.opencode_server_username,
password="password",
schemas_dir=package_dir / "prompts" / "schemas",
health_directory=settings.workspaces_dir,
required_models=(
(settings.plan_model, settings.plan_variant),
(settings.implement_model, settings.implement_variant),
(settings.explore_model, settings.explore_variant),
(settings.research_model, settings.research_variant),
),
timeout_seconds=settings.turn_timeout_seconds,
)
development_constructor.assert_called_once_with(
scripts=["python"],
scripts_dir=settings.install_scripts_dir,
tools_dir=settings.dev_tools_dir,
timeout_seconds=settings.install_script_timeout_seconds,
python_version=settings.python_version,
dotnet_channel=settings.dotnet_channel,
)
prompt_constructor.assert_called_once_with()
services_constructor.assert_called_once_with(
settings=settings,
repository=repository,
gitea=gitea,
git=git,
opencode=opencode,
prompts=prompts,
development=development,
)
worker_constructor.assert_called_once_with(
repository=repository,
gitea=gitea,
opencode=opencode,
services=services,
poll_seconds=settings.worker_poll_seconds,
max_concurrent_jobs=4,
workspaces_dir=settings.workspaces_dir,
bot_username=settings.bot_username,
)
assert settings.data_dir.is_dir()
assert settings.workspaces_dir.is_dir()
assert built.repository is repository
assert built.gitea is gitea
assert built.git is git
assert built.opencode is opencode
assert built.worker is worker
+89
View File
@@ -0,0 +1,89 @@
import json
import os
import stat
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[1]
def test_entrypoint_loads_secrets_writes_tea_login_and_executes_command(
tmp_path: Path,
) -> None:
password = tmp_path / "opencode-password"
password.write_text("server-secret")
token = tmp_path / "gitea-token"
token.write_text("gitea-secret\n")
config_home = tmp_path / "config"
environment = {
**os.environ,
"OPENCODE_SERVER_PASSWORD_FILE": str(password),
"AGENTCI_GITEA_TOKEN_FILE": str(token),
"AGENTCI_TEA_CONFIG_HOME": str(config_home),
"AGENTCI_GITEA_URL": "https://gitea.example",
}
result = subprocess.run(
[
"/bin/sh",
str(ROOT / "scripts" / "entrypoint.sh"),
"/bin/sh",
"-c",
'printf "%s\\n" "$OPENCODE_SERVER_PASSWORD"; printf "arg=%s\\n" "$@"',
"child",
"first",
"second value",
],
env=environment,
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == [
"server-secret",
"arg=first",
"arg=second value",
]
config_path = config_home / "tea" / "config.yml"
assert json.loads(config_path.read_text()) == {
"logins": [
{
"name": "agentci",
"url": "https://gitea.example",
"token": "gitea-secret",
"default": True,
"version_check": False,
}
],
"preferences": {},
}
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
@pytest.mark.parametrize(
("prompt", "expected"),
[
("Username for 'https://gitea.example':", "agentci-bot\n"),
("Password for 'https://agentci@gitea.example':", "token-secret\n"),
],
)
def test_gitea_askpass_selects_credential_for_prompt(prompt: str, expected: str) -> None:
result = subprocess.run(
["/bin/sh", str(ROOT / "scripts" / "gitea-askpass.sh"), prompt],
env={
"AGENTCI_GIT_USERNAME": "agentci-bot",
"AGENTCI_GIT_PASSWORD": "token-secret",
},
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout == expected
-75
View File
@@ -1,75 +0,0 @@
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"))
-156
View File
@@ -1,156 +0,0 @@
import sqlite3
from pathlib import Path
import pytest
from agentci.adapters.storage import Storage
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
async def storage(tmp_path: Path) -> Storage:
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
value = Storage(tmp_path / "state.sqlite3", migrations)
await value.initialize()
return value
def command(
delivery: str,
body: str = "/agent plan",
*,
issue: int = 3,
pr: int | None = None,
) -> CommandEvent:
return CommandEvent(
delivery_id=delivery,
comment_id=int(delivery.rsplit("-", 1)[-1]),
repo_owner="alice",
repo_name="repo",
issue_number=issue,
pr_number=pr,
requester="alice",
body=body,
)
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_received_job_blocks_later_execute_task_for_same_target(
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_received_job_does_not_block_a_different_target(storage: Storage) -> None:
host = StateMachine(storage)
await host.receive(command("delivery-1", issue=3))
second = (await host.receive(command("delivery-2", issue=4))).state
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
task = await storage.claim_task("jobs")
assert task is not None
assert task.job_id == second.id
async def test_claims_multiple_eligible_targets_without_duplicates(storage: Storage) -> None:
host = StateMachine(storage)
first = (await host.receive(command("delivery-1", issue=3))).state
second = (await host.receive(command("delivery-2", issue=4))).state
await host.evolve("grant-1", PermissionGranted(job_id=first.id))
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
claimed = [await storage.claim_task("jobs"), await storage.claim_task("jobs")]
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id]
assert await storage.claim_task("jobs") is 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,
repo_owner="alice",
repo_name="repo",
issue_number=3,
workspace_path=tmp_path / "repo",
base_sha="abc",
artifact="# Plan",
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.status is WorkflowStatus.COMPLETED
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-atomic",
kind=WorkflowKind.PLAN,
repo_owner="alice",
repo_name="repo",
issue_number=3,
workspace_path=tmp_path / "repo",
base_sha="abc",
)
result = await host.evolve(
"workflow-created",
WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
)
assert result.state.workflow_id == workflow.id
assert await storage.get_workflow(workflow.id) is not None
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
+147 -35
View File
@@ -1,28 +1,31 @@
import hashlib import hashlib
import hmac import hmac
import json
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from fastapi import HTTPException from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature from agentci.engine.model import IncomingCommand
from agentci.webhook import _event_from_payload, router, valid_signature
class FakeHost: class FakeRepository:
def __init__(self, duplicate: bool = False) -> None: def __init__(self, *, duplicate: bool = False) -> None:
self.events = [] self.accepted: list[IncomingCommand] = []
self.duplicate = duplicate self.duplicate = duplicate
async def receive(self, event): async def accept(self, event: IncomingCommand) -> SimpleNamespace:
self.events.append(event) self.accepted.append(event)
state = SimpleNamespace(id="job", receive_sequence=1) job = SimpleNamespace(id="job", receive_sequence=1)
return SimpleNamespace(state=state, duplicate=self.duplicate) return SimpleNamespace(job=job, duplicate=self.duplicate)
def payload(body: str, *, is_pull: bool = False) -> dict: def payload(body: str, *, is_pull: bool = False, requester: str = "alice") -> dict:
value = { value = {
"action": "created", "action": "created",
"comment": {"id": 8, "body": body, "user": {"login": "alice"}}, "comment": {"id": 8, "body": body, "user": {"login": requester}},
"repository": {"name": "repo", "owner": {"login": "org"}}, "repository": {"name": "repo", "owner": {"login": "org"}},
"issue": {"number": 4}, "issue": {"number": 4},
"is_pull": is_pull, "is_pull": is_pull,
@@ -32,40 +35,149 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
return value return value
async def test_command_is_forwarded_without_parsing() -> None: def encoded(value: object) -> bytes:
host = FakeHost() return json.dumps(value).encode()
event = _event_from_payload(
"delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
def sign(body: bytes, secret: bytes = b"secret") -> str:
return hmac.new(secret, body, hashlib.sha256).hexdigest()
async def post_webhook(
repository: FakeRepository,
body: bytes,
*,
event: str = "issue_comment",
delivery: str | None = "delivery",
signature: str | None = None,
bot_username: str = "agentci",
) -> Response:
app = FastAPI()
app.include_router(router)
app.state.runtime = SimpleNamespace(
repository=repository,
settings=SimpleNamespace(webhook_secret=b"secret", bot_username=bot_username),
) )
assert event is not None headers = {
response = await _handle_command(SimpleNamespace(state_machine=host), event) "X-Gitea-Event-Type": event,
"X-Gitea-Signature": sign(body) if signature is None else signature,
}
if delivery is not None:
headers["X-Gitea-Delivery"] = delivery
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
return await client.post("/webhooks/gitea", content=body, headers=headers)
async def test_signed_supported_command_is_accepted_once_with_raw_body() -> None:
repository = FakeRepository()
body = encoded(payload("/agent iterate\n\nkeep raw body", is_pull=True))
response = await post_webhook(repository, body)
assert response.status_code == 202 assert response.status_code == 202
assert host.events[0].body == "/agent iterate\n\nkeep raw body" assert len(repository.accepted) == 1
event = repository.accepted[0]
assert event.delivery_id == "delivery"
assert event.target_key == "org/repo:pr:4"
assert event.requester == "alice"
assert event.body == "/agent iterate\n\nkeep raw body"
async def test_duplicate_returns_200() -> None: @pytest.mark.parametrize("event", ["issue_comment", "push"])
event = _event_from_payload("delivery", payload("/agent plan")) async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str) -> None:
assert event is not None repository = FakeRepository()
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
assert response.status_code == 200 response = await post_webhook(repository, b"not-json", event=event, signature="invalid")
assert response.status_code == 401
assert response.json() == {"detail": "Invalid webhook signature"}
assert repository.accepted == []
async def test_missing_delivery_is_rejected() -> None: async def test_signed_unsupported_event_is_ignored_without_parsing() -> None:
event = _event_from_payload("", payload("/agent plan")) repository = FakeRepository()
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
response = await post_webhook(repository, b"not-json", event="push")
async def test_non_command_is_ignored() -> None:
event = _event_from_payload("delivery", payload("ordinary discussion"))
assert event is not None
response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
assert response.status_code == 204 assert response.status_code == 204
assert repository.accepted == []
def test_signature_validation() -> None: @pytest.mark.parametrize(
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest() ("command", "requester"),
[("ordinary discussion", "alice"), ("/agent plan", "AgentCI")],
)
async def test_non_command_and_bot_comment_are_ignored(command: str, requester: str) -> None:
repository = FakeRepository()
response = await post_webhook(repository, encoded(payload(command, requester=requester)))
assert response.status_code == 204
assert repository.accepted == []
async def test_duplicate_delivery_returns_ok() -> None:
repository = FakeRepository(duplicate=True)
response = await post_webhook(repository, encoded(payload("/agent plan")))
assert response.status_code == 200
assert len(repository.accepted) == 1
@pytest.mark.parametrize(
"body",
[
b"{",
encoded([]),
encoded({"action": "created"}),
encoded({"action": "created", "comment": {}}),
],
)
async def test_malformed_json_and_payload_contract_return_bad_request(body: bytes) -> None:
repository = FakeRepository()
response = await post_webhook(repository, body)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid webhook payload"}
assert repository.accepted == []
async def test_command_requires_delivery_header() -> None:
repository = FakeRepository()
response = await post_webhook(repository, encoded(payload("/agent plan")), delivery=None)
assert response.status_code == 400
assert response.json() == {"detail": "Missing X-Gitea-Delivery"}
assert repository.accepted == []
async def test_non_created_comment_is_ignored_without_nested_payload() -> None:
repository = FakeRepository()
response = await post_webhook(repository, encoded({"action": "edited"}))
assert response.status_code == 204
assert repository.accepted == []
def test_payload_parser_supports_owner_username_and_pull_request() -> None:
value = payload("/agent plan", is_pull=True)
value["repository"]["owner"] = {"username": "fallback-owner"}
event = _event_from_payload("delivery", value)
assert event is not None
assert event.repo_owner == "fallback-owner"
assert event.issue_number == 4
assert event.pr_number == 4
def test_signature_validation_requires_exact_nonempty_digest() -> None:
signature = sign(b"{}")
assert valid_signature(b"secret", b"{}", signature) assert valid_signature(b"secret", b"{}", signature)
assert not valid_signature(b"secret", b"{}", "")
assert not valid_signature(b"secret", b"{}", "bad") assert not valid_signature(b"secret", b"{}", "bad")
+597 -42
View File
@@ -1,44 +1,586 @@
import asyncio import asyncio
from dataclasses import replace
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast
from agentci.domain.models import Workflow, WorkflowKind import pytest
from agentci.domain.state_machine import JobState
from agentci.engine.events import (
CommentLinked,
JobCompleted,
JobFailed,
JobStarted,
PermissionDenied,
PermissionGranted,
ServiceRestarted,
)
from agentci.engine.events import (
JobRejected as RejectedEvent,
)
from agentci.engine.model import (
IncomingCommand,
Job,
JobKind,
JobStatus,
QueueName,
Task,
TaskKind,
Workflow,
WorkflowKind,
)
from agentci.engine.reducer import render_job_comment
from agentci.engine.repository import Repository
from agentci.gitea import CommentInfo
from agentci.worker import Worker, _safe_error from agentci.worker import Worker, _safe_error
from agentci.workflows.render import JobRejected
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
class FakeStorage: class FakeRepository:
def __init__(self, workflow=None) -> None: def __init__(
self,
value: Job | None = None,
*,
workflow: Workflow | None = None,
) -> None:
self.job = value
self.workflow = workflow self.workflow = workflow
self.tasks: list[Task] = []
self.claimed_queues: list[QueueName] = []
self.completed: list[int] = []
self.retried: list[tuple[int, int, str]] = []
self.events: list[tuple[str, object]] = []
self.operations: list[str] = []
self.running: list[Job] = []
self.failed_workflows: list[str] = []
self.complete_stop: asyncio.Event | None = None
async def get_workflow(self, _workflow_id): async def claim_task(self, queue: QueueName) -> Task | None:
self.claimed_queues.append(queue)
return self.tasks.pop(0) if self.tasks else None
async def complete_task(self, task_id: int) -> None:
self.completed.append(task_id)
if self.complete_stop is not None:
self.complete_stop.set()
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
self.retried.append((task_id, attempts, error))
async def get_job(self, _job_id: str) -> Job | None:
return self.job
async def apply(self, event_id: str, event: object) -> SimpleNamespace:
self.operations.append(f"apply:{event_id}")
self.events.append((event_id, event))
if isinstance(event, JobStarted) and self.job is not None:
self.job = replace(self.job, status=JobStatus.RUNNING, stage="starting")
elif isinstance(event, CommentLinked) and self.job is not None:
self.job = replace(self.job, accepted_comment_id=event.comment_id)
return SimpleNamespace(job=self.job)
async def recover_tasks(self) -> None:
self.operations.append("recover_tasks")
async def running_jobs(self) -> list[Job]:
self.operations.append("running_jobs")
return self.running
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
return self.workflow return self.workflow
async def fail_job_workflow(self, job_id: str) -> None:
self.failed_workflows.append(job_id)
class FakeGitea:
def __init__(self, *, permitted: bool = True) -> None:
self.permitted = permitted
self.permission_calls: list[tuple[str, str, str]] = []
self.update_results: list[bool] = [True]
self.update_calls: list[tuple[str, str, int, str]] = []
self.comments: list[CommentInfo] = []
self.issue_comment_calls: list[tuple[str, str, int]] = []
self.created_comment_id = 42
self.create_calls: list[tuple[str, str, int, str]] = []
async def has_write_permission(self, owner: str, repo: str, user: str) -> bool:
self.permission_calls.append((owner, repo, user))
return self.permitted
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
self.update_calls.append((owner, repo, comment_id, body))
if len(self.update_results) > 1:
return self.update_results.pop(0)
return self.update_results[0]
async def issue_comments(self, owner: str, repo: str, issue: int) -> list[CommentInfo]:
self.issue_comment_calls.append((owner, repo, issue))
return self.comments
async def create_comment(self, owner: str, repo: str, issue: int, body: str) -> int:
self.create_calls.append((owner, repo, issue, body))
return self.created_comment_id
class FakeOpenCode: class FakeOpenCode:
def __init__(self) -> None: def __init__(self, ready: list[bool] | None = None) -> None:
self.aborted = set() self.ready_results = ready or [True]
self.ready_calls = 0
self.aborted: list[tuple[str, Path]] = []
async def abort(self, session_id, workspace): async def ready(self) -> bool:
self.aborted.add((session_id, workspace)) self.ready_calls += 1
if len(self.ready_results) > 1:
return self.ready_results.pop(0)
return self.ready_results[0]
async def abort(self, session_id: str, workspace: Path) -> None:
self.aborted.append((session_id, workspace))
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker: def job(
*,
job_id: str = "job",
status: JobStatus = JobStatus.RECEIVED,
workflow_id: str | None = None,
session_id: str | None = None,
accepted_comment_id: int | None = None,
) -> Job:
return Job(
id=job_id,
target_key="org/repo:issue:1",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=None,
requester="alice",
comment_id=1,
delivery_id=f"delivery-{job_id}",
receive_sequence=1,
command_body="/agent plan",
kind=JobKind.PLAN if status is not JobStatus.RECEIVED else None,
status=status,
stage=status.value,
workflow_id=workflow_id,
runtime_session_id=session_id,
accepted_comment_id=accepted_comment_id,
)
def task(
kind: TaskKind = TaskKind.EXECUTE,
queue: QueueName = QueueName.JOBS,
*,
task_id: int = 7,
) -> Task:
return Task(task_id, "job", "source", kind, queue, 2)
def make_worker(
tmp_path: Path,
repository: FakeRepository | Repository,
*,
gitea: FakeGitea | None = None,
opencode: FakeOpenCode | None = None,
max_concurrent_jobs: int = 2,
) -> Worker:
return Worker( return Worker(
storage=storage, # type: ignore[arg-type] repository=repository, # type: ignore[arg-type]
state_machine=SimpleNamespace(), # type: ignore[arg-type] gitea=gitea or FakeGitea(), # type: ignore[arg-type]
gitea=SimpleNamespace(), # type: ignore[arg-type] opencode=opencode or FakeOpenCode(), # type: ignore[arg-type]
opencode=opencode, # type: ignore[arg-type] services=SimpleNamespace(), # type: ignore[arg-type]
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
poll_seconds=1, poll_seconds=1,
max_concurrent_jobs=2, max_concurrent_jobs=max_concurrent_jobs,
workspaces_dir=tmp_path, workspaces_dir=tmp_path,
bot_username="agentci", bot_username="agentci",
) )
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None: async def test_loop_completes_successful_task(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
repository = FakeRepository()
queued_task = task(queue=QueueName.CONTROL)
repository.tasks = [queued_task]
stop = asyncio.Event()
handled: list[Task] = []
value = make_worker(tmp_path, repository)
async def handle(current: Task) -> None:
handled.append(current)
stop.set()
monkeypatch.setattr(value, "_handle", handle)
await value._loop(QueueName.CONTROL, stop)
assert handled == [queued_task]
assert repository.completed == [queued_task.id]
assert repository.retried == []
async def test_loop_retries_failed_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
repository = FakeRepository()
queued_task = task(queue=QueueName.CONTROL)
repository.tasks = [queued_task]
stop = asyncio.Event()
value = make_worker(tmp_path, repository)
async def fail(_current: Task) -> None:
stop.set()
raise RuntimeError("provider\nfailed")
monkeypatch.setattr(value, "_handle", fail)
await value._loop(QueueName.CONTROL, stop)
assert repository.completed == []
assert repository.retried == [
(queued_task.id, queued_task.attempts, "RuntimeError: provider failed")
]
async def test_loop_propagates_cancellation_without_rescheduling(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
repository = FakeRepository()
queued_task = task(queue=QueueName.CONTROL)
repository.tasks = [queued_task]
stop = asyncio.Event()
value = make_worker(tmp_path, repository)
async def cancel(_current: Task) -> None:
stop.set()
raise asyncio.CancelledError
monkeypatch.setattr(value, "_handle", cancel)
with pytest.raises(asyncio.CancelledError):
await value._loop(QueueName.CONTROL, stop)
assert repository.completed == []
assert repository.retried == []
async def test_loop_completes_task_whose_job_no_longer_exists(tmp_path: Path) -> None:
repository = FakeRepository(None)
missing_job_task = task(queue=QueueName.CONTROL)
repository.tasks = [missing_job_task]
stop = asyncio.Event()
repository.complete_stop = stop
await make_worker(tmp_path, repository)._loop(QueueName.CONTROL, stop)
assert repository.completed == [missing_job_task.id]
assert repository.retried == []
async def test_jobs_queue_waits_for_provider_readiness(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
repository = FakeRepository()
queued_task = task()
repository.tasks = [queued_task]
opencode = FakeOpenCode([False, True])
stop = asyncio.Event()
waits: list[asyncio.Event] = []
value = make_worker(tmp_path, repository, opencode=opencode)
async def wait(current_stop: asyncio.Event) -> None:
waits.append(current_stop)
async def handle(_current: Task) -> None:
stop.set()
monkeypatch.setattr(value, "_wait", wait)
monkeypatch.setattr(value, "_handle", handle)
await value._loop(QueueName.JOBS, stop)
assert opencode.ready_calls == 2
assert waits == [stop]
assert repository.claimed_queues == [QueueName.JOBS]
assert repository.completed == [queued_task.id]
async def test_run_recovers_before_starting_configured_consumers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
value = make_worker(tmp_path, FakeRepository(), max_concurrent_jobs=3)
calls: list[str | QueueName] = []
async def recover() -> None:
calls.append("recover")
async def loop(queue: QueueName, _stop: asyncio.Event) -> None:
calls.append(queue)
monkeypatch.setattr(value, "_recover", recover)
monkeypatch.setattr(value, "_loop", loop)
await value.run(asyncio.Event())
assert calls[0] == "recover"
assert calls.count(QueueName.CONTROL) == 1
assert calls.count(QueueName.JOBS) == 3
@pytest.mark.parametrize(
("permitted", "event_type", "suffix"),
[
(True, PermissionGranted, "permission-granted"),
(False, PermissionDenied, "permission-denied"),
],
)
async def test_authorize_records_permission_outcome(
tmp_path: Path,
permitted: bool,
event_type: type[PermissionGranted] | type[PermissionDenied],
suffix: str,
) -> None:
current = job()
repository = FakeRepository(current)
gitea = FakeGitea(permitted=permitted)
authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL)
await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current)
assert gitea.permission_calls == [("org", "repo", "alice")]
assert repository.events[0][0] == f"task:{authorize.id}:{suffix}"
assert isinstance(repository.events[0][1], event_type)
async def test_authorize_ignores_job_after_received_status(tmp_path: Path) -> None:
current = job(status=JobStatus.QUEUED)
repository = FakeRepository(current)
gitea = FakeGitea()
await make_worker(tmp_path, repository, gitea=gitea)._authorize(
task(TaskKind.AUTHORIZE, QueueName.CONTROL), current
)
assert gitea.permission_calls == []
assert repository.events == []
async def test_authorize_integrates_with_real_repository(
tmp_path: Path,
) -> None:
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
await repository.initialize()
accepted = await repository.accept(
IncomingCommand(
delivery_id="delivery-real",
comment_id=1,
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=None,
requester="alice",
body="/agent plan write tests",
)
)
authorize = await repository.claim_task(QueueName.CONTROL)
assert authorize is not None
assert authorize.kind is TaskKind.AUTHORIZE
await make_worker(tmp_path, repository)._handle(authorize)
persisted = await repository.get_job(accepted.job.id)
assert persisted is not None
assert persisted.status is JobStatus.QUEUED
assert persisted.kind is JobKind.PLAN
assert persisted.message == "write tests"
execute = await repository.claim_task(QueueName.JOBS)
assert execute is not None
assert execute.kind is TaskKind.EXECUTE
async def test_execute_completes_with_workflow_comment(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
current = job(status=JobStatus.QUEUED)
repository = FakeRepository(current)
value = make_worker(tmp_path, repository)
async def dispatch(_job: Job, _run: object, _services: object) -> str:
return "final workflow body"
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
execute = task()
await value._execute(execute, current)
assert [event_id for event_id, _ in repository.events] == [
f"task:{execute.id}:started",
f"task:{execute.id}:completed",
]
assert isinstance(repository.events[0][1], JobStarted)
completed = repository.events[1][1]
assert isinstance(completed, JobCompleted)
assert completed.comment_body == "final workflow body"
async def test_execute_records_expected_rejection(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
current = job(status=JobStatus.QUEUED)
repository = FakeRepository(current)
async def dispatch(_job: Job, _run: object, _services: object) -> str:
raise JobRejected("pull request is closed")
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
await make_worker(tmp_path, repository)._execute(task(), current)
rejected = repository.events[-1][1]
assert isinstance(rejected, RejectedEvent)
assert rejected.reason == "pull request is closed"
async def test_execute_records_failure_at_latest_persisted_stage(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
current = job(status=JobStatus.QUEUED)
repository = FakeRepository(current)
async def dispatch(_job: Job, _run: object, _services: object) -> str:
assert repository.job is not None
repository.job = replace(repository.job, stage="cloning")
raise RuntimeError("provider\nfailed")
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
await make_worker(tmp_path, repository)._execute(task(), current)
failed = repository.events[-1][1]
assert isinstance(failed, JobFailed)
assert failed.stage == "cloning"
assert failed.error == "RuntimeError: provider failed"
async def test_execute_marks_running_job_interrupted_after_restart(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
current = job(status=JobStatus.RUNNING)
repository = FakeRepository(current)
async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str:
pytest.fail("running jobs must not be dispatched again")
monkeypatch.setattr("agentci.worker.dispatch", unexpected_dispatch)
execute = task()
await make_worker(tmp_path, repository)._execute(execute, current)
assert repository.events[0][0] == f"task:{execute.id}:interrupted"
assert isinstance(repository.events[0][1], ServiceRestarted)
@pytest.mark.parametrize("status", [JobStatus.RECEIVED, JobStatus.SUCCEEDED])
async def test_execute_ignores_ineligible_status(tmp_path: Path, status: JobStatus) -> None:
current = job(status=status)
repository = FakeRepository(current)
await make_worker(tmp_path, repository)._execute(task(), current)
assert repository.events == []
async def test_reconcile_updates_linked_comment_in_place(tmp_path: Path) -> None:
current = job(status=JobStatus.QUEUED, accepted_comment_id=19)
repository = FakeRepository(current)
gitea = FakeGitea()
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(
task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), current
)
assert gitea.update_calls == [("org", "repo", 19, render_job_comment(current))]
assert gitea.issue_comment_calls == []
assert gitea.create_calls == []
assert repository.events == []
async def test_reconcile_finds_oldest_matching_bot_comment_and_links_it(
tmp_path: Path,
) -> None:
current = job(status=JobStatus.QUEUED, accepted_comment_id=99)
marker = f"<!-- agentci:job id={current.id} -->"
repository = FakeRepository(current)
gitea = FakeGitea()
gitea.update_results = [False, True]
gitea.comments = [
CommentInfo(8, "agentci", f"{marker}\nnewer", ""),
CommentInfo(3, "AgentCI", f"{marker}\nolder", ""),
CommentInfo(1, "alice", f"{marker}\nnot the bot", ""),
CommentInfo(2, "agentci", f"prefix {marker}", ""),
]
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
assert gitea.issue_comment_calls == [("org", "repo", 1)]
assert gitea.create_calls == []
assert [call[2] for call in gitea.update_calls] == [99, 3]
assert repository.events[0][0] == f"task:{reconcile.id}:comment:3"
linked = repository.events[0][1]
assert isinstance(linked, CommentLinked)
assert linked.comment_id == 3
async def test_reconcile_creates_links_and_populates_missing_comment(tmp_path: Path) -> None:
current = job(status=JobStatus.SUCCEEDED)
repository = FakeRepository(current)
gitea = FakeGitea()
gitea.comments = [CommentInfo(1, "alice", "unrelated", "")]
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
body = render_job_comment(current)
assert gitea.create_calls == [("org", "repo", 1, body)]
assert gitea.update_calls == [("org", "repo", 42, body)]
assert repository.events[0][0] == f"task:{reconcile.id}:comment:42"
linked = repository.events[0][1]
assert isinstance(linked, CommentLinked)
assert linked.comment_id == 42
async def test_recovery_resets_tasks_before_interrupting_running_jobs(tmp_path: Path) -> None:
repository = FakeRepository()
repository.running = [
job(job_id="first", status=JobStatus.RUNNING),
job(job_id="second", status=JobStatus.RUNNING),
]
await make_worker(tmp_path, repository)._recover()
assert repository.operations == [
"recover_tasks",
"running_jobs",
"apply:recovery:first:service-restarted",
"apply:recovery:second:service-restarted",
]
assert all(isinstance(event, ServiceRestarted) for _, event in repository.events)
@pytest.mark.parametrize(
("primary", "reviewer", "expected"),
[
("primary", "reviewer", {"primary", "reviewer"}),
("same-session", "same-session", {"same-session"}),
],
)
async def test_abort_collects_and_deduplicates_workflow_sessions(
tmp_path: Path,
primary: str,
reviewer: str,
expected: set[str],
) -> None:
workspace = tmp_path / "workflow" / "repo" workspace = tmp_path / "workflow" / "repo"
workflow = Workflow( workflow = Workflow(
id="flow", id="flow",
@@ -48,46 +590,59 @@ async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
issue_number=1, issue_number=1,
workspace_path=workspace, workspace_path=workspace,
base_sha="base", base_sha="base",
primary_session_id="primary", primary_session_id=primary,
reviewer_session_id="reviewer", reviewer_session_id=reviewer,
) )
opencode = FakeOpenCode() opencode = FakeOpenCode()
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions( await make_worker(
cast(JobState, state) tmp_path, FakeRepository(workflow=workflow), opencode=opencode
) )._abort_job_sessions(job(workflow_id="flow"))
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
assert set(opencode.aborted) == {(session, workspace) for session in expected}
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None: async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None:
opencode = FakeOpenCode() opencode = FakeOpenCode()
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions( await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(
cast(JobState, state) job(workflow_id="missing", session_id="session")
) )
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")]
async def test_run_starts_configured_job_consumers(tmp_path: Path, monkeypatch) -> None: async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
value = worker(tmp_path, FakeStorage(), FakeOpenCode()) tmp_path: Path,
queues = [] ) -> None:
workflow = Workflow(
id="flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=tmp_path / "workflow" / "repo",
base_sha="base",
)
opencode = FakeOpenCode()
async def recover() -> None: await make_worker(
pass tmp_path, FakeRepository(workflow=workflow), opencode=opencode
)._abort_job_sessions(job(workflow_id="flow", session_id="one-shot"))
async def loop(queue, _stop) -> None: assert opencode.aborted == []
queues.append(queue)
monkeypatch.setattr(value, "_recover", recover)
monkeypatch.setattr(value, "_loop", loop)
await value.run(asyncio.Event()) async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
opencode = FakeOpenCode()
assert queues.count("control") == 1 await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job())
assert queues.count("jobs") == 2
assert opencode.aborted == []
def test_safe_error_is_single_line_and_bounded() -> None: def test_safe_error_is_single_line_and_bounded() -> None:
value = _safe_error(RuntimeError("bad\n" + "x" * 2000)) value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
assert "\n" not in value assert "\n" not in value
assert len(value) == 1000 assert len(value) == 1000
+93
View File
@@ -0,0 +1,93 @@
from dataclasses import replace
from enum import StrEnum
from typing import cast
import pytest
from agentci.engine.model import Job, JobKind
from agentci.engine.run import JobRun
from agentci.workflows import dispatch as dispatch_module
from agentci.workflows.services import WorkflowServices
ROUTES = [
(JobKind.PLAN, "create_plan"),
(JobKind.DISCUSS, "discuss_plan"),
(JobKind.ITERATE_PLAN, "iterate_plan"),
(JobKind.IMPLEMENT, "implement"),
(JobKind.ITERATE_IMPLEMENT, "iterate_implementation"),
(JobKind.FIX, "fix_pull_request"),
]
def make_job(kind: JobKind | None) -> Job:
return Job(
id="job-1",
kind=kind,
target_key="org/repo:issue:7",
repo_owner="org",
repo_name="repo",
issue_number=7,
pr_number=None,
requester="alice",
comment_id=11,
delivery_id="delivery-1",
receive_sequence=1,
command_body="/agent plan",
message="request",
)
@pytest.mark.parametrize(("kind", "expected_route"), ROUTES)
async def test_dispatch_routes_every_job_kind_and_returns_body(
monkeypatch: pytest.MonkeyPatch,
kind: JobKind,
expected_route: str,
) -> None:
calls: list[tuple[str, Job]] = []
def route(name: str):
async def invoke(job: Job, _run: JobRun, _services: WorkflowServices) -> str:
calls.append((name, job))
return f"body from {name}"
return invoke
for _, name in ROUTES:
monkeypatch.setattr(dispatch_module, name, route(name))
job = make_job(kind)
body = await dispatch_module.dispatch(
job,
cast(JobRun, object()),
cast(WorkflowServices, object()),
)
assert body == f"body from {expected_route}"
assert calls == [(expected_route, job)]
async def test_dispatch_rejects_unparsed_command() -> None:
with pytest.raises(RuntimeError, match="Cannot dispatch an unparsed command"):
await dispatch_module.dispatch(
make_job(None),
cast(JobRun, object()),
cast(WorkflowServices, object()),
)
class UnsupportedKind(StrEnum):
UNKNOWN = "unknown"
async def test_dispatch_rejects_unsupported_kind() -> None:
unsupported = UnsupportedKind.UNKNOWN
job = replace(make_job(JobKind.PLAN), kind=cast(JobKind, unsupported))
with pytest.raises(KeyError) as error:
await dispatch_module.dispatch(
job,
cast(JobRun, object()),
cast(WorkflowServices, object()),
)
assert error.value.args == (unsupported,)
+392
View File
@@ -0,0 +1,392 @@
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.implementation import implement
from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
self.created_workflows: list[tuple[Workflow, str]] = []
self.linked_sessions: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
self.created_workflows.append((workflow, stage))
async def link_session(self, session_id: str) -> None:
self.linked_sessions.append(session_id)
class FakeRepository:
def __init__(
self,
*,
implementations: list[Workflow] | None = None,
plan: Workflow | None = None,
) -> None:
self.implementations = implementations or []
self.plan = plan
self.saved_workflows: list[Workflow] = []
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
return self.implementations
async def latest_workflow(self, *_args: object) -> Workflow | None:
return self.plan
async def operational_comment_ids(self, *_args: object) -> set[int]:
return {99}
async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow)
class FakeGitea:
def __init__(
self,
*,
existing_pulls: dict[int, PullRequestInfo] | None = None,
) -> None:
self.existing_pulls = existing_pulls or {}
self.created_pulls: list[tuple[str, str, dict[str, str]]] = []
self.default_branch_calls: list[tuple[str, str]] = []
async def default_branch(self, owner: str, repo: str) -> str:
self.default_branch_calls.append((owner, repo))
return "main"
async def issue(self, *_args: object) -> IssueInfo:
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
return [
CommentInfo(12, "alice", "Please cover empty input.", "2026-07-01"),
CommentInfo(99, "agentci", "Job queued", "2026-07-02"),
]
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
return self.existing_pulls[number]
async def create_pull_request(self, owner: str, repo: str, **values: str) -> PullRequestInfo:
self.created_pulls.append((owner, repo, values))
return pull(number=42, branch=values["head"])
class RecordingGit:
def __init__(self, *, changed: bool = True) -> None:
self.changed = changed
self.calls: list[tuple[Any, ...]] = []
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
self.calls.append(("clone", owner, repo, branch, destination))
return "base-sha"
async def create_branch(self, workspace: Path, branch: str) -> None:
self.calls.append(("create_branch", workspace, branch))
async def has_changes(self, workspace: Path) -> bool:
self.calls.append(("has_changes", workspace))
return self.changed
async def diff_check(self, workspace: Path) -> None:
self.calls.append(("diff_check", workspace))
async def commit(self, workspace: Path, message: str) -> str:
self.calls.append(("commit", workspace, message))
return "commit-sha"
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
self.calls.append(("push", workspace, branch, set_upstream))
class RecordingDevelopment:
description = "Python 3.13"
def __init__(self) -> None:
self.workspaces: list[Path] = []
async def prepare(self, workspace: Path) -> None:
self.workspaces.append(workspace)
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
class RecordingOpenCode:
def __init__(self, result: AgentResult, report: ReviewReport) -> None:
self.result = result
self.report = report
self.created_sessions: list[tuple[Path, str]] = []
self.resume_calls: list[dict[str, Any]] = []
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> AgentResult | ReviewReport:
self.resume_calls.append(values)
if values["result_type"] is AgentResult:
return self.result
assert values["result_type"] is ReviewReport
return self.report
def job() -> Job:
return Job(
id="job-1",
kind=JobKind.IMPLEMENT,
target_key="org/repo:issue:7",
repo_owner="org",
repo_name="repo",
issue_number=7,
pr_number=None,
requester="alice",
comment_id=10,
delivery_id="delivery-1",
receive_sequence=1,
command_body="/agent implement",
message="Keep the change focused.",
)
def workflow(*, pr_number: int | None = None) -> Workflow:
return Workflow(
id=f"old-{pr_number}",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=7,
workspace_path=Path("/old/repo"),
base_sha="old-sha",
pr_number=pr_number,
status=WorkflowStatus.COMPLETED,
)
def pull(
*,
number: int = 8,
state: str = "open",
merged: bool = False,
branch: str = "agent/old",
) -> PullRequestInfo:
return PullRequestInfo(
number=number,
title="Existing PR",
body="Body",
state=state,
merged=merged,
base_branch="main",
head_branch=branch,
head_sha="head-sha",
head_owner="org",
head_repo="repo",
)
def make_services(
tmp_path: Path,
*,
changed: bool = True,
implementations: list[Workflow] | None = None,
existing_pulls: dict[int, PullRequestInfo] | None = None,
plan: Workflow | None = None,
) -> tuple[
WorkflowServices,
FakeRepository,
FakeGitea,
RecordingGit,
RecordingDevelopment,
RecordingPrompts,
RecordingOpenCode,
]:
repository = FakeRepository(implementations=implementations, plan=plan)
gitea = FakeGitea(existing_pulls=existing_pulls)
git = RecordingGit(changed=changed)
development = RecordingDevelopment()
prompts = RecordingPrompts()
opencode = RecordingOpenCode(
AgentResult(
summary_markdown="# Implement widget\n\nHandled empty input.",
tests=["pytest: passed"],
),
ReviewReport(summary="Ready", findings=[]),
)
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
branch_prefix="agent",
workspaces_dir=tmp_path / "workspaces",
gitea_url="https://git.example.test",
implement_model="provider/model",
implement_variant="high",
implement_review_rounds=2,
),
repository=repository,
gitea=gitea,
git=git,
opencode=opencode,
prompts=prompts,
development=development,
),
)
return services, repository, gitea, git, development, prompts, opencode
async def test_initial_implementation_completes_review_commit_push_and_pr(
tmp_path: Path,
) -> None:
stale = workflow(pr_number=8)
services, repository, gitea, git, development, prompts, opencode = make_services(
tmp_path,
implementations=[stale],
existing_pulls={8: pull(state="closed")},
)
run = RecordingRun()
body = await implement(job(), run, services)
assert len(run.created_workflows) == 1
created, created_stage = run.created_workflows[0]
assert created_stage == "installing development environment"
assert created.status is WorkflowStatus.ACTIVE
assert created.branch == f"agent/issue-7-{created.id[:8]}"
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
assert created.base_sha == "base-sha"
assert development.workspaces == [created.workspace_path]
assert run.linked_sessions == ["implementation-session"]
assert opencode.created_sessions == [
(created.workspace_path, "implementation"),
(created.workspace_path, "implementation-review"),
]
assert [call["result_type"] for call in opencode.resume_calls] == [
AgentResult,
ReviewReport,
]
initial_prompt = prompts.calls[0]
assert initial_prompt[0] == "implement_initial"
assert initial_prompt[1]["artifact"] == "(no canonical plan)"
assert initial_prompt[1]["request"] == "Keep the change focused."
assert "Please cover empty input." in initial_prompt[1]["context"]
assert "Job queued" not in initial_prompt[1]["context"]
assert git.calls == [
("clone", "org", "repo", "main", created.workspace_path),
("create_branch", created.workspace_path, created.branch),
("has_changes", created.workspace_path),
("diff_check", created.workspace_path),
("commit", created.workspace_path, "agent: Implement widget"),
("push", created.workspace_path, created.branch, True),
]
assert gitea.created_pulls == [
(
"org",
"repo",
{
"title": "Agent: Fix widget",
"body": (
"Closes #7\n\n"
"## Implementation\n\n# Implement widget\n\nHandled empty input.\n\n"
"## Validation\n\n- pytest: passed\n\n_Created by Agent CI._"
),
"head": created.branch,
"base": "main",
},
)
]
completed = repository.saved_workflows[-1]
assert completed.id == created.id
assert completed.status is WorkflowStatus.COMPLETED
assert completed.pr_number == 42
assert completed.primary_session_id == "implementation-session"
assert completed.reviewer_session_id == "implementation-review-session"
assert AgentResult.model_validate_json(completed.artifact or "").tests == ["pytest: passed"]
assert json.loads(completed.review_json or "") == {
"summary": "Ready",
"findings": [],
}
assert body == (
f"<!-- agentci:implementation workflow={created.id} -->\n"
"Pull request created: https://git.example.test/org/repo/pulls/42\n\n"
"## Agent result\n\n# Implement widget\n\nHandled empty input.\n\n"
"## Validation\n\n- pytest: passed\n\nCommit: `commit-sha`"
)
async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr(
tmp_path: Path,
) -> None:
services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False)
run = RecordingRun()
with pytest.raises(
JobRejected,
match="OpenCode completed without producing any file changes",
):
await implement(job(), run, services)
assert [call[0] for call in git.calls] == [
"clone",
"create_branch",
"has_changes",
]
assert gitea.created_pulls == []
assert repository.saved_workflows
assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows)
assert "creating pull request" not in run.stages
@pytest.mark.parametrize(
("existing_pull", "message"),
[
(
pull(state="open"),
"Agent PR #8 is already open. Use `/agent iterate` on that pull request.",
),
(
pull(state="closed", merged=True),
"Agent PR #8 has already been merged for this issue.",
),
],
)
async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone(
tmp_path: Path,
existing_pull: PullRequestInfo,
message: str,
) -> None:
services, _, gitea, git, development, _, opencode = make_services(
tmp_path,
implementations=[workflow(pr_number=8)],
existing_pulls={8: existing_pull},
)
with pytest.raises(JobRejected) as error:
await implement(job(), RecordingRun(), services)
assert str(error.value) == message
assert git.calls == []
assert development.workspaces == []
assert opencode.resume_calls == []
assert gitea.default_branch_calls == []
+439
View File
@@ -0,0 +1,439 @@
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pydantic import BaseModel
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
self.created_workflows: list[tuple[Workflow, str]] = []
self.linked_workflows: list[tuple[str, str]] = []
self.linked_sessions: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
self.created_workflows.append((workflow, stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
self.linked_workflows.append((workflow_id, stage))
async def link_session(self, session_id: str) -> None:
self.linked_sessions.append(session_id)
class FakeRepository:
def __init__(
self,
*,
latest: Workflow | None = None,
implementations: list[Workflow] | None = None,
) -> None:
self.latest = latest
self.implementations = implementations or []
self.saved_workflows: list[Workflow] = []
self.latest_calls = 0
async def latest_workflow(self, *_args: object) -> Workflow | None:
self.latest_calls += 1
return self.latest
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
return self.implementations
async def operational_comment_ids(self, *_args: object) -> set[int]:
return {91}
async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow)
self.latest = workflow
class FakeGitea:
def __init__(self, pulls: dict[int, PullRequestInfo] | None = None) -> None:
self.pulls = pulls or {}
self.default_branch_calls: list[tuple[str, str]] = []
self.pull_calls: list[int] = []
async def default_branch(self, owner: str, repo: str) -> str:
self.default_branch_calls.append((owner, repo))
return "trunk"
async def issue(self, *_args: object) -> IssueInfo:
return IssueInfo(3, "Plan feature", "Build the feature.", "open")
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
return [
CommentInfo(4, "alice", "Use the existing API.", "2026-07-01"),
CommentInfo(91, "agentci", "Job queued", "2026-07-02"),
]
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
self.pull_calls.append(number)
return self.pulls[number]
class RecordingGit:
def __init__(self) -> None:
self.clone_calls: list[tuple[str, str, str, Path]] = []
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
self.clone_calls.append((owner, repo, branch, destination))
return "base-sha"
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
class RecordingOpenCode:
def __init__(self, responses: list[BaseModel] | None = None) -> None:
self.responses = list(responses or [])
self.created_sessions: list[tuple[Path, str]] = []
self.resume_calls: list[dict[str, Any]] = []
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> BaseModel:
self.resume_calls.append(values)
response = self.responses.pop(0)
assert isinstance(response, values["result_type"])
return response
def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job:
return Job(
id="job-plan",
kind=kind,
target_key="org/repo:issue:3",
repo_owner="org",
repo_name="repo",
issue_number=3,
pr_number=None,
requester="alice",
comment_id=5,
delivery_id="delivery-plan",
receive_sequence=1,
command_body="/agent plan",
message=message,
)
def plan_workflow(
*,
runtime: str = "opencode",
primary_session_id: str | None = "primary-session",
reviewer_session_id: str | None = "reviewer-session",
artifact: str | None = "Original plan",
) -> Workflow:
return Workflow(
id="plan-flow",
kind=WorkflowKind.PLAN,
repo_owner="org",
repo_name="repo",
issue_number=3,
workspace_path=Path("/workspace/plan"),
base_sha="base-sha",
runtime=runtime,
primary_session_id=primary_session_id,
reviewer_session_id=reviewer_session_id,
artifact=artifact,
review_json='{"summary":"Prior","findings":[]}',
status=WorkflowStatus.COMPLETED,
)
def implementation_workflow(pr_number: int | None = 9) -> Workflow:
return Workflow(
id="implementation-flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=3,
workspace_path=Path("/workspace/implementation"),
base_sha="base",
pr_number=pr_number,
status=WorkflowStatus.COMPLETED,
)
def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo:
return PullRequestInfo(
number=9,
title="Agent implementation",
body="Body",
state=state,
merged=merged,
base_branch="trunk",
head_branch="agent/feature",
head_sha="head",
head_owner="org",
head_repo="repo",
)
def make_services(
tmp_path: Path,
*,
latest: Workflow | None = None,
implementations: list[Workflow] | None = None,
pulls: dict[int, PullRequestInfo] | None = None,
responses: list[BaseModel] | None = None,
) -> tuple[
WorkflowServices,
FakeRepository,
FakeGitea,
RecordingGit,
RecordingPrompts,
RecordingOpenCode,
]:
repository = FakeRepository(latest=latest, implementations=implementations)
gitea = FakeGitea(pulls)
git = RecordingGit()
prompts = RecordingPrompts()
opencode = RecordingOpenCode(responses)
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
workspaces_dir=tmp_path / "workspaces",
plan_model="provider/model",
plan_variant="high",
plan_review_rounds=3,
),
repository=repository,
gitea=gitea,
git=git,
prompts=prompts,
opencode=opencode,
),
)
return services, repository, gitea, git, prompts, opencode
async def test_create_plan_completes_primary_and_independent_review(
tmp_path: Path,
) -> None:
services, repository, gitea, git, prompts, opencode = make_services(
tmp_path,
responses=[
PlanArtifact(plan_markdown="# Complete plan"),
ReviewReport(summary="Ready", findings=[]),
],
)
run = RecordingRun()
body = await create_plan(job(JobKind.PLAN), run, services)
created, stage = run.created_workflows[0]
assert stage == "planning"
assert created.kind is WorkflowKind.PLAN
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
assert git.clone_calls == [("org", "repo", "trunk", created.workspace_path)]
assert gitea.default_branch_calls == [("org", "repo")]
assert run.linked_sessions == ["plan-session"]
assert opencode.created_sessions == [
(created.workspace_path, "plan"),
(created.workspace_path, "plan-review"),
]
assert [call["result_type"] for call in opencode.resume_calls] == [
PlanArtifact,
ReviewReport,
]
assert prompts.calls[0][0] == "plan_initial"
assert prompts.calls[0][1]["request"] == "Please be specific."
assert "Use the existing API." in prompts.calls[0][1]["context"]
assert "Job queued" not in prompts.calls[0][1]["context"]
completed = repository.saved_workflows[-1]
assert completed.status is WorkflowStatus.COMPLETED
assert completed.primary_session_id == "plan-session"
assert completed.reviewer_session_id == "plan-review-session"
assert completed.artifact == "# Complete plan"
assert json.loads(completed.review_json or "") == {
"summary": "Ready",
"findings": [],
}
assert body == f"<!-- agentci:plan workflow={created.id} -->\n# Complete plan"
async def test_discuss_plan_resumes_primary_session_without_replacing_artifact(
tmp_path: Path,
) -> None:
existing = plan_workflow()
services, repository, _, _, prompts, opencode = make_services(
tmp_path,
latest=existing,
responses=[DiscussionReply(markdown="The API remains compatible.")],
)
run = RecordingRun()
body = await discuss_plan(job(JobKind.DISCUSS), run, services)
assert run.linked_workflows == [(existing.id, "discussing")]
assert opencode.created_sessions == []
assert opencode.resume_calls[0]["session_id"] == "primary-session"
assert opencode.resume_calls[0]["schema_name"] == "discussion.json"
assert prompts.calls == [
(
"discuss",
{"artifact": "Original plan", "message": "Please be specific."},
)
]
assert repository.saved_workflows == []
assert body == ("<!-- agentci:discussion workflow=plan-flow -->\nThe API remains compatible.")
@pytest.mark.parametrize(
("latest", "message"),
[
(None, "No completed plan exists. Start with `/agent plan`."),
(
plan_workflow(runtime="codex"),
"The latest plan predates OpenCode and cannot be resumed; start a new `/agent plan`.",
),
(
plan_workflow(primary_session_id=None),
"The latest plan cannot be resumed; start a new `/agent plan`.",
),
(
plan_workflow(artifact=None),
"The latest plan cannot be resumed; start a new `/agent plan`.",
),
],
)
async def test_discuss_plan_rejects_missing_or_incompatible_plan(
tmp_path: Path,
latest: Workflow | None,
message: str,
) -> None:
services, _, _, _, _, opencode = make_services(tmp_path, latest=latest)
with pytest.raises(JobRejected) as error:
await discuss_plan(job(JobKind.DISCUSS), RecordingRun(), services)
assert str(error.value) == message
assert opencode.resume_calls == []
async def test_iterate_plan_revises_then_reuses_reviewer_session(
tmp_path: Path,
) -> None:
existing = plan_workflow()
services, repository, gitea, _, prompts, opencode = make_services(
tmp_path,
latest=existing,
implementations=[implementation_workflow(), implementation_workflow(None)],
pulls={9: pull(state="closed")},
responses=[
PlanArtifact(plan_markdown="# Revised plan"),
ReviewReport(summary="Ready", findings=[]),
],
)
run = RecordingRun()
body = await iterate_plan(job(JobKind.ITERATE_PLAN, message=None), run, services)
assert gitea.pull_calls == [9]
assert run.linked_workflows == [(existing.id, "iterating plan")]
assert opencode.created_sessions == []
assert [call["session_id"] for call in opencode.resume_calls] == [
"primary-session",
"reviewer-session",
]
assert [call["result_type"] for call in opencode.resume_calls] == [
PlanArtifact,
ReviewReport,
]
iterate_prompt = prompts.calls[0]
assert iterate_prompt[0] == "plan_iterate"
assert iterate_prompt[1]["artifact"] == "Original plan"
assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)")
assert json.loads(iterate_prompt[1]["review"]) == {
"summary": "Prior",
"findings": [],
}
completed = repository.saved_workflows[-1]
assert completed.artifact == "# Revised plan"
assert completed.status is WorkflowStatus.COMPLETED
assert body == "<!-- agentci:plan workflow=plan-flow -->\n# Revised plan"
@pytest.mark.parametrize(
("latest", "message"),
[
(None, "No completed plan exists. Start with `/agent plan`."),
(
plan_workflow(runtime="codex"),
"The latest plan predates OpenCode; start a new plan.",
),
(
plan_workflow(primary_session_id=None),
"The latest plan is missing resumable sessions; start a new plan.",
),
(
plan_workflow(reviewer_session_id=None),
"The latest plan is missing resumable sessions; start a new plan.",
),
(plan_workflow(artifact=None), "The latest plan has no saved artifact."),
],
)
async def test_iterate_plan_rejects_missing_or_incompatible_plan(
tmp_path: Path,
latest: Workflow | None,
message: str,
) -> None:
services, repository, _, _, _, opencode = make_services(tmp_path, latest=latest)
with pytest.raises(JobRejected) as error:
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
assert str(error.value) == message
assert repository.saved_workflows == []
assert opencode.resume_calls == []
@pytest.mark.parametrize(
"blocking_pull",
[pull(state="open"), pull(state="closed", merged=True)],
ids=["open", "merged"],
)
async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged(
tmp_path: Path, blocking_pull: PullRequestInfo
) -> None:
services, repository, _, _, _, opencode = make_services(
tmp_path,
latest=plan_workflow(),
implementations=[implementation_workflow()],
pulls={9: blocking_pull},
)
with pytest.raises(JobRejected) as error:
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
assert str(error.value) == (
"Issue plan iteration is disabled because agent PR #9 is open or merged. "
"Iterate an open implementation on its PR."
)
assert repository.latest_calls == 0
assert opencode.resume_calls == []
+502
View File
@@ -0,0 +1,502 @@
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pydantic import BaseModel
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
self.linked_workflows: list[tuple[str, str]] = []
self.linked_sessions: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
async def link_workflow(self, workflow_id: str, stage: str) -> None:
self.linked_workflows.append((workflow_id, stage))
async def link_session(self, session_id: str) -> None:
self.linked_sessions.append(session_id)
class FakeRepository:
def __init__(
self,
*,
workflow: Workflow | None = None,
plan: Workflow | None = None,
) -> None:
self.workflow = workflow
self.plan = plan
self.saved_workflows: list[Workflow] = []
async def workflow_for_pr(self, *_args: object) -> Workflow | None:
return self.workflow
async def latest_workflow(self, *_args: object) -> Workflow | None:
return self.plan
async def operational_comment_ids(self, *_args: object) -> set[int]:
return set()
async def save_workflow(self, workflow: Workflow) -> None:
self.workflow = workflow
self.saved_workflows.append(workflow)
class FakeGitea:
def __init__(self, pull: PullRequestInfo) -> None:
self.pull = pull
self.pull_calls: list[tuple[str, str, int]] = []
async def pull_request(self, owner: str, repo: str, number: int) -> PullRequestInfo:
self.pull_calls.append((owner, repo, number))
return self.pull
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
return [CommentInfo(1, "alice", "Please add coverage.", "2026-07-01")]
async def pull_reviews(self, *_args: object) -> list[dict[str, Any]]:
return [
{
"id": 3,
"user": {"login": "bob"},
"state": "REQUEST_CHANGES",
"body": "Handle empty input.",
}
]
async def review_comments(self, *_args: object) -> list[dict[str, Any]]:
return [{"path": "src/widget.py", "new_position": 8, "body": "Add a guard."}]
async def pull_commits(self, *_args: object) -> list[dict[str, Any]]:
return [{"sha": "abcdef1234567890", "commit": {"message": "Initial change"}}]
async def issue(self, *_args: object) -> IssueInfo:
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
class RecordingGit:
def __init__(self, *, changed: bool = True) -> None:
self.changed = changed
self.calls: list[tuple[Any, ...]] = []
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
self.calls.append(("clone", owner, repo, branch, destination))
return "head-sha"
async def sync_branch(self, workspace: Path, branch: str) -> str:
self.calls.append(("sync_branch", workspace, branch))
return "head-sha"
async def has_changes(self, workspace: Path) -> bool:
self.calls.append(("has_changes", workspace))
return self.changed
async def diff_check(self, workspace: Path) -> None:
self.calls.append(("diff_check", workspace))
async def commit(self, workspace: Path, message: str) -> str:
self.calls.append(("commit", workspace, message))
return "new-sha"
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
self.calls.append(("push", workspace, branch, set_upstream))
class RecordingDevelopment:
description = "Python 3.13"
def __init__(self) -> None:
self.workspaces: list[Path] = []
async def prepare(self, workspace: Path) -> None:
self.workspaces.append(workspace)
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
class RecordingOpenCode:
def __init__(self, responses: list[BaseModel] | None = None) -> None:
self.responses = list(responses or [])
self.created_sessions: list[tuple[Path, str]] = []
self.resume_calls: list[dict[str, Any]] = []
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> BaseModel:
self.resume_calls.append(values)
response = self.responses.pop(0)
assert isinstance(response, values["result_type"])
return response
def job(kind: JobKind, *, pr_number: int | None = 12) -> Job:
return Job(
id="job-pr",
kind=kind,
target_key="org/repo:pr:12",
repo_owner="org",
repo_name="repo",
issue_number=7,
pr_number=pr_number,
requester="alice",
comment_id=9,
delivery_id="delivery-pr",
receive_sequence=1,
command_body="/agent iterate",
message="Handle the review.",
)
def implementation_workflow(
*,
status: WorkflowStatus = WorkflowStatus.COMPLETED,
runtime: str = "opencode",
branch: str | None = "agent/issue-7",
primary_session_id: str | None = "primary-session",
reviewer_session_id: str | None = "reviewer-session",
) -> Workflow:
return Workflow(
id="implementation-flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=7,
workspace_path=Path("/workspace/implementation"),
base_sha="base-sha",
runtime=runtime,
branch=branch,
pr_number=12,
primary_session_id=primary_session_id,
reviewer_session_id=reviewer_session_id,
artifact='{"summary_markdown":"Prior","tests":[]}',
review_json='{"summary":"Prior review","findings":[]}',
status=status,
)
def plan_workflow() -> Workflow:
return Workflow(
id="plan-flow",
kind=WorkflowKind.PLAN,
repo_owner="org",
repo_name="repo",
issue_number=7,
workspace_path=Path("/workspace/plan"),
base_sha="base",
artifact="Canonical plan",
status=WorkflowStatus.COMPLETED,
)
def pull(
*,
state: str = "open",
merged: bool = False,
branch: str = "agent/issue-7",
) -> PullRequestInfo:
return PullRequestInfo(
number=12,
title="Fix widget",
body="Implementation body",
state=state,
merged=merged,
base_branch="main",
head_branch=branch,
head_sha="abcdef1234567890",
head_owner="contributor",
head_repo="fork",
)
def make_services(
tmp_path: Path,
*,
workflow: Workflow | None = None,
pull_info: PullRequestInfo | None = None,
plan: Workflow | None = None,
changed: bool = True,
responses: list[BaseModel] | None = None,
) -> tuple[
WorkflowServices,
FakeRepository,
FakeGitea,
RecordingGit,
RecordingDevelopment,
RecordingPrompts,
RecordingOpenCode,
]:
repository = FakeRepository(workflow=workflow, plan=plan)
gitea = FakeGitea(pull_info or pull())
git = RecordingGit(changed=changed)
development = RecordingDevelopment()
prompts = RecordingPrompts()
opencode = RecordingOpenCode(responses)
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
workspaces_dir=tmp_path / "workspaces",
implement_model="provider/model",
implement_variant="high",
),
repository=repository,
gitea=gitea,
git=git,
development=development,
prompts=prompts,
opencode=opencode,
),
)
return services, repository, gitea, git, development, prompts, opencode
def result(summary: str = "# Refine widget") -> AgentResult:
return AgentResult(summary_markdown=summary, tests=["pytest: passed"])
async def test_iterate_implementation_reuses_sessions_reviews_commits_and_pushes(
tmp_path: Path,
) -> None:
existing = implementation_workflow()
services, repository, _, git, development, prompts, opencode = make_services(
tmp_path,
workflow=existing,
plan=plan_workflow(),
responses=[result(), ReviewReport(summary="Ready", findings=[])],
)
run = RecordingRun()
body = await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), run, services)
assert run.linked_workflows == [(existing.id, "synchronizing branch")]
assert development.workspaces == [existing.workspace_path]
assert opencode.created_sessions == []
assert [call["session_id"] for call in opencode.resume_calls] == [
"primary-session",
"reviewer-session",
]
assert [call["result_type"] for call in opencode.resume_calls] == [
AgentResult,
ReviewReport,
]
iterate_prompt = prompts.calls[0]
assert iterate_prompt[0] == "implementation_iterate"
assert iterate_prompt[1]["message"] == "Handle the review."
assert "Handle empty input." in iterate_prompt[1]["context"]
review_prompt = prompts.calls[1]
assert review_prompt[0] == "implementation_review"
assert review_prompt[1]["artifact"] == "Canonical plan"
assert "The widget is broken." in review_prompt[1]["issue_context"]
assert git.calls == [
("sync_branch", existing.workspace_path, "agent/issue-7"),
("has_changes", existing.workspace_path),
("diff_check", existing.workspace_path),
("commit", existing.workspace_path, "agent iterate: Refine widget"),
("push", existing.workspace_path, "agent/issue-7", False),
]
saved = repository.saved_workflows[-1]
assert AgentResult.model_validate_json(saved.artifact or "") == result()
assert json.loads(saved.review_json or "") == {
"summary": "Ready",
"findings": [],
}
assert body == (
"<!-- agentci:iteration workflow=implementation-flow -->\n"
"## Agent result\n\n# Refine widget\n\n"
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
)
@pytest.mark.parametrize(
("pr_number", "existing", "message"),
[
(None, implementation_workflow(), "This command requires a pull request."),
(
12,
None,
"This is not an open agent-created implementation PR. Use `/agent fix`.",
),
(
12,
implementation_workflow(status=WorkflowStatus.ACTIVE),
"This is not an open agent-created implementation PR. Use `/agent fix`.",
),
(
12,
implementation_workflow(runtime="codex"),
"The implementation predates OpenCode and cannot be resumed.",
),
(
12,
implementation_workflow(primary_session_id=None),
"The implementation sessions cannot be resumed.",
),
(
12,
implementation_workflow(reviewer_session_id=None),
"The implementation sessions cannot be resumed.",
),
],
)
async def test_iterate_implementation_rejects_missing_stale_or_incompatible_workflow(
tmp_path: Path,
pr_number: int | None,
existing: Workflow | None,
message: str,
) -> None:
services, repository, gitea, git, development, _, opencode = make_services(
tmp_path, workflow=existing
)
with pytest.raises(JobRejected) as error:
await iterate_implementation(
job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number),
RecordingRun(),
services,
)
assert str(error.value) == message
assert repository.saved_workflows == []
assert gitea.pull_calls == []
assert git.calls == []
assert development.workspaces == []
assert opencode.resume_calls == []
@pytest.mark.parametrize(
("pull_info", "existing", "message"),
[
(
pull(state="closed"),
implementation_workflow(),
"Implementation iteration requires an open pull request.",
),
(
pull(branch="renamed-branch"),
implementation_workflow(),
"The pull request head branch no longer matches its workflow.",
),
],
ids=["closed", "branch-mismatch"],
)
async def test_iterate_implementation_rejects_closed_or_stale_branch_before_checkout(
tmp_path: Path,
pull_info: PullRequestInfo,
existing: Workflow,
message: str,
) -> None:
services, repository, _, git, development, _, opencode = make_services(
tmp_path, workflow=existing, pull_info=pull_info
)
with pytest.raises(JobRejected) as error:
await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), RecordingRun(), services)
assert str(error.value) == message
assert repository.saved_workflows == []
assert git.calls == []
assert development.workspaces == []
assert opencode.resume_calls == []
async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes(
tmp_path: Path,
) -> None:
services, repository, _, git, development, prompts, opencode = make_services(
tmp_path,
responses=[result("Fix empty input")],
)
run = RecordingRun()
body = await fix_pull_request(job(JobKind.FIX), run, services)
workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo"
assert git.calls == [
("clone", "contributor", "fork", "agent/issue-7", workspace),
("has_changes", workspace),
("diff_check", workspace),
("commit", workspace, "agent fix: Fix empty input"),
("push", workspace, "agent/issue-7", False),
]
assert development.workspaces == [workspace]
assert opencode.created_sessions == [(workspace, "fix")]
assert run.linked_sessions == ["fix-session"]
assert opencode.resume_calls[0]["session_id"] == "fix-session"
assert opencode.resume_calls[0]["result_type"] is AgentResult
assert prompts.calls[0][0] == "fix"
assert "`src/widget.py:8`: Add a guard." in prompts.calls[0][1]["context"]
assert repository.saved_workflows == []
assert body == (
"<!-- agentci:fix workflow=job-pr -->\n"
"## Agent result\n\nFix empty input\n\n"
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
)
async def test_fix_pull_request_rejects_clean_worktree_before_push(
tmp_path: Path,
) -> None:
services, _, _, git, _, _, _ = make_services(
tmp_path,
changed=False,
responses=[result("No changes required")],
)
with pytest.raises(
JobRejected,
match="OpenCode completed without producing any file changes",
):
await fix_pull_request(job(JobKind.FIX), RecordingRun(), services)
assert [call[0] for call in git.calls] == ["clone", "has_changes"]
@pytest.mark.parametrize(
("pr_number", "pull_info", "message"),
[
(None, pull(), "This command requires a pull request."),
(12, pull(state="closed"), "Fixes require an open pull request."),
(12, pull(state="closed", merged=True), "Fixes require an open pull request."),
],
ids=["missing-pr", "closed", "merged"],
)
async def test_fix_pull_request_requires_open_pr_before_clone(
tmp_path: Path,
pr_number: int | None,
pull_info: PullRequestInfo,
message: str,
) -> None:
services, _, gitea, git, development, _, opencode = make_services(tmp_path, pull_info=pull_info)
with pytest.raises(JobRejected) as error:
await fix_pull_request(job(JobKind.FIX, pr_number=pr_number), RecordingRun(), services)
assert str(error.value) == message
assert git.calls == []
assert development.workspaces == []
assert opencode.resume_calls == []
assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)])
+117
View File
@@ -0,0 +1,117 @@
import json
import pytest
from agentci.workflows.model import (
AgentResult,
ReviewFinding,
ReviewReport,
ReviewSeverity,
)
from agentci.workflows.render import (
agent_comment,
commit_title,
final_comment,
pull_request_body,
report_for_prompt,
required_session,
result_comment,
review_markdown,
)
def report() -> ReviewReport:
return ReviewReport(
summary="One issue remains.",
findings=[
ReviewFinding(
severity=ReviewSeverity.MAJOR,
title="Missing validation",
detail="The empty input is not checked.",
location="src/widget.py:12",
recommendation="Reject empty input.",
)
],
)
def test_review_markdown_renders_user_visible_finding() -> None:
assert review_markdown(report()) == (
"## Remaining review findings\n\n"
"One issue remains.\n\n"
"### MAJOR: Missing validation \u2014 `src/widget.py:12`\n"
"The empty input is not checked.\n\n"
"Recommendation: Reject empty input."
)
assert review_markdown(ReviewReport(summary="Ready", findings=[])) == ""
def test_agent_and_final_comments_preserve_protocol_marker() -> None:
assert agent_comment("plan", "flow-1", "Plan body") == (
"<!-- agentci:plan workflow=flow-1 -->\nPlan body"
)
assert final_comment("plan", "flow-1", "Plan body", report()) == (
f"<!-- agentci:plan workflow=flow-1 -->\nPlan body\n\n{review_markdown(report())}"
)
assert (
final_comment("plan", "flow-1", "Plan body", ReviewReport(summary="Ready", findings=[]))
== "<!-- agentci:plan workflow=flow-1 -->\nPlan body"
)
def test_pull_request_body_and_result_comment_render_validation() -> None:
result = AgentResult(
summary_markdown="Implemented the widget fix.",
tests=["pytest: passed", "ruff: passed"],
)
assert pull_request_body(17, result) == (
"Closes #17\n\n"
"## Implementation\n\nImplemented the widget fix.\n\n"
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
"_Created by Agent CI._"
)
assert result_comment(result, sha="abc123") == (
"## Agent result\n\nImplemented the widget fix.\n\n"
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
"Commit: `abc123`"
)
def test_renderers_report_missing_validation() -> None:
result = AgentResult(summary_markdown="Applied the change.", tests=[])
assert "## Validation\n\n- Not reported" in pull_request_body(2, result)
assert result_comment(result) == (
"## Agent result\n\nApplied the change.\n\n## Validation\n\n- Not reported"
)
@pytest.mark.parametrize(
("markdown", "expected"),
[
("# Fix widget\n\nDetails", "Fix widget"),
("\n## Trim heading \n", "Trim heading"),
("\n\t\n", "apply requested changes"),
("x" * 80, "x" * 72),
],
)
def test_commit_title_uses_first_content_line(markdown: str, expected: str) -> None:
assert commit_title(markdown) == expected
def test_report_for_prompt_formats_json_and_preserves_unstructured_text() -> None:
stored = '{"summary":"Ready","findings":[]}'
formatted = report_for_prompt(stored)
assert json.loads(formatted) == {"summary": "Ready", "findings": []}
assert formatted == '{\n "summary": "Ready",\n "findings": []\n}'
assert report_for_prompt("plain text review") == "plain text review"
assert report_for_prompt(None) == "(none)"
def test_required_session_returns_value_or_fails_fast() -> None:
assert required_session("session-1") == "session-1"
with pytest.raises(RuntimeError, match="Expected a persisted OpenCode session ID"):
required_session(None)
Generated
+70 -14
View File
@@ -19,7 +19,7 @@ dev = [
{ name = "pyright" }, { name = "pyright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "respx" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -37,7 +37,7 @@ dev = [
{ name = "pyright", specifier = ">=1.1.403" }, { name = "pyright", specifier = ">=1.1.403" },
{ name = "pytest", specifier = ">=8.4,<9" }, { name = "pytest", specifier = ">=8.4,<9" },
{ name = "pytest-asyncio", specifier = ">=1.1,<2" }, { name = "pytest-asyncio", specifier = ">=1.1,<2" },
{ name = "respx", specifier = ">=0.22,<1" }, { name = "pytest-cov", specifier = ">=6,<8" },
{ name = "ruff", specifier = ">=0.12,<1" }, { name = "ruff", specifier = ">=0.12,<1" },
] ]
@@ -101,6 +101,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "coverage"
version = "7.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.139.2" version = "0.139.2"
@@ -363,6 +417,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
] ]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -408,18 +476,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "respx"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.22" version = "0.15.22"