diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore
new file mode 100644
index 0000000..d20c0fe
--- /dev/null
+++ b/.codegraph/.gitignore
@@ -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
diff --git a/.env.example b/.env.example
index 88c6b67..b99be15 100644
--- a/.env.example
+++ b/.env.example
@@ -1,10 +1,10 @@
GITEA_NETWORK=gitea
+AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:latest
AGENTCI_GITEA_URL=http://gitea:3000
AGENTCI_BOT_USERNAME=agentci
AGENTCI_BOT_NAME=Agent CI
AGENTCI_BOT_EMAIL=agentci@localhost
OPENCODE_SERVER_USERNAME=opencode
-AGENTCI_OPENCODE_VERSION=^1
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
AGENTCI_PLAN_VARIANT=
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
@@ -24,4 +24,3 @@ AGENTCI_INSTALL_SCRIPTS=
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0
-CODEGRAPH_VERSION=1.3.1
diff --git a/.gitea/workflows/publish-image.yaml b/.gitea/workflows/publish-image.yaml
new file mode 100644
index 0000000..7621a7d
--- /dev/null
+++ b/.gitea/workflows/publish-image.yaml
@@ -0,0 +1,41 @@
+name: Publish container image
+
+on:
+ push:
+ branches:
+ - master
+
+env:
+ REGISTRY: git.krtss.de
+ IMAGE_NAME: git.krtss.de/stanponomarev/agentci
+
+jobs:
+ publish:
+ name: Build and push
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_TOKEN }}
+
+ - name: Build and push image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ pull: true
+ tags: |
+ ${{ env.IMAGE_NAME }}:latest
+ ${{ env.IMAGE_NAME }}:${{ gitea.sha }}
+ labels: |
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..b58b603
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/agentci.iml b/.idea/agentci.iml
new file mode 100644
index 0000000..34264c8
--- /dev/null
+++ b/.idea/agentci.iml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..f6b3aba
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/pyLspTools.xml b/.idea/pyLspTools.xml
new file mode 100644
index 0000000..28adef1
--- /dev/null
+++ b/.idea/pyLspTools.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c1371d6
--- /dev/null
+++ b/AGENTS.md
@@ -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/application/`: runtime composition, durable control/job queues, task handlers,
+ recovery, and comment reconciliation.
+- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
+- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
+- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
+- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
+- `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_.py
+uv run pytest tests/test_.py::test_
+uv run pytest -k ''
+```
+
+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.
diff --git a/README.md b/README.md
index babdeed..eae0930 100644
--- a/README.md
+++ b/README.md
@@ -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
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. `application/worker/` executes those tasks
+and passes an explicit `JobRun` into the functions under `workflows/`. Modules under
+`integrations/` own external effects, while `api/` contains the FastAPI host and routes.
+
+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
| Location | Command | Behavior |
@@ -29,10 +46,11 @@ recreated.
OpenCode models.
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
-4. Build the image:
+4. Log in to the Gitea container registry with a personal access token, then pull the image:
```sh
- docker compose build
+ docker login git.krtss.de
+ docker compose pull
```
5. Authenticate the configured OpenCode providers before starting the persistent server:
@@ -58,6 +76,22 @@ deployment, run the one-off `auth login` command above and then `docker compose
healthy and every configured model exists, supports tool calls, accepts its configured variant, and
has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
+### Image publishing
+
+Every push to `master` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both
+`git.krtss.de/stanponomarev/agentci:latest` and
+`git.krtss.de/stanponomarev/agentci:`. Configure these repository Actions secrets
+before the first run:
+
+| Secret | Value |
+| --- | --- |
+| `REGISTRY_USERNAME` | Username that owns the package or can write packages for the owner. |
+| `REGISTRY_TOKEN` | Personal access token with package write permission. |
+
+The automatic Gitea Actions token cannot publish packages. Compose uses `latest` by default; set
+`AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:` in `.env` to deploy an immutable
+revision.
+
## OpenCode
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
@@ -67,10 +101,11 @@ configures the research subagent and defaults to `high`. `AGENTCI_EXPLORE_MODEL`
`AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to
`openai/gpt-5.6-luna` with `low`.
-`AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults
-to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker
-may reuse the cached installation layer until the configured version or build inputs change.
-Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built.
+The `AGENTCI_OPENCODE_VERSION` Docker build argument controls the npm version or range installed into
+the image and defaults to `^1`. The build verifies that the resolved version is still OpenCode 1.x
+and prints it. Docker may reuse the cached installation layer until the configured version or build
+inputs change. Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it
+is built.
The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
@@ -153,9 +188,11 @@ uv sync
uv run ruff check .
uv run pyright
uv run pytest
+uv run pytest --cov=agentci --cov-branch
docker compose config
-docker compose build
```
-The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
-Python so orchestration modules remain small and readable.
+The coverage command is an opt-in diagnostic report; the regular test run remains the default and
+coverage percentage is not used as a pass threshold.
+
+Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow.
diff --git a/compose.yaml b/compose.yaml
index 2762d9c..f1a451e 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,12 +1,6 @@
services:
agentci:
- image: agentci:local
- build:
- context: .
- args:
- AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}
- CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
- TEA_VERSION: ${TEA_VERSION:-0.14.2}
+ image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
restart: unless-stopped
depends_on:
opencode:
@@ -39,7 +33,7 @@ services:
- webhook_secret
- opencode_server_password
volumes:
- - agentci_data:/var/lib/agentci
+ - ./data/agentci:/var/lib/agentci
- ./install-scripts:/etc/agentci/install-scripts:ro
tmpfs:
- /run/agentci:mode=1777
@@ -50,7 +44,7 @@ services:
- agentci_control
opencode:
- image: agentci:local
+ image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
restart: unless-stopped
environment:
@@ -81,8 +75,8 @@ services:
- gitea_token
- opencode_server_password
volumes:
- - agentci_data:/var/lib/agentci
- - opencode_home:/var/lib/opencode
+ - ./data/agentci:/var/lib/agentci
+ - ./data/opencode:/var/lib/opencode
tmpfs:
- /run/agentci:mode=1777
expose:
@@ -105,10 +99,6 @@ secrets:
opencode_server_password:
file: ./secrets/opencode_server_password
-volumes:
- agentci_data:
- opencode_home:
-
networks:
gitea:
external: true
diff --git a/pyproject.toml b/pyproject.toml
index 8503baa..c28c83a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dev = [
"pyright>=1.1.403",
"pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2",
- "respx>=0.22,<1",
+ "pytest-cov>=6,<8",
"ruff>=0.12,<1",
]
diff --git a/src/agentci/__init__.py b/src/agentci/__init__.py
index 80392d7..e1f9488 100644
--- a/src/agentci/__init__.py
+++ b/src/agentci/__init__.py
@@ -1,3 +1 @@
"""Gitea-triggered OpenCode workflow host."""
-
-__version__ = "0.1.0"
diff --git a/src/agentci/__main__.py b/src/agentci/__main__.py
index 5945a05..03fc141 100644
--- a/src/agentci/__main__.py
+++ b/src/agentci/__main__.py
@@ -2,8 +2,8 @@ from __future__ import annotations
import uvicorn
-from agentci.app import create_app
-from agentci.config import Settings
+from agentci.api.app import create_app
+from agentci.config.settings import Settings
def main() -> None:
@@ -19,4 +19,3 @@ def main() -> None:
if __name__ == "__main__":
main()
-
diff --git a/src/agentci/adapters/__init__.py b/src/agentci/adapters/__init__.py
deleted file mode 100644
index 5461621..0000000
--- a/src/agentci/adapters/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-"""External-system adapters."""
-
diff --git a/src/agentci/adapters/database.py b/src/agentci/adapters/database.py
deleted file mode 100644
index 4c858d8..0000000
--- a/src/agentci/adapters/database.py
+++ /dev/null
@@ -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)
diff --git a/src/agentci/adapters/job_store.py b/src/agentci/adapters/job_store.py
deleted file mode 100644
index 8f38e26..0000000
--- a/src/agentci/adapters/job_store.py
+++ /dev/null
@@ -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,
- )
diff --git a/src/agentci/adapters/opencode_support.py b/src/agentci/adapters/opencode_support.py
deleted file mode 100644
index 6e17904..0000000
--- a/src/agentci/adapters/opencode_support.py
+++ /dev/null
@@ -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
diff --git a/src/agentci/adapters/state_persistence.py b/src/agentci/adapters/state_persistence.py
deleted file mode 100644
index cea99ec..0000000
--- a/src/agentci/adapters/state_persistence.py
+++ /dev/null
@@ -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,
- ),
- )
diff --git a/src/agentci/adapters/storage.py b/src/agentci/adapters/storage.py
deleted file mode 100644
index 88089b5..0000000
--- a/src/agentci/adapters/storage.py
+++ /dev/null
@@ -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."""
-
diff --git a/src/agentci/adapters/workflow_store.py b/src/agentci/adapters/workflow_store.py
deleted file mode 100644
index 3037fad..0000000
--- a/src/agentci/adapters/workflow_store.py
+++ /dev/null
@@ -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"]),
- )
diff --git a/src/agentci/api/__init__.py b/src/agentci/api/__init__.py
index 94d8bb0..f6c85c6 100644
--- a/src/agentci/api/__init__.py
+++ b/src/agentci/api/__init__.py
@@ -1,2 +1 @@
-"""HTTP API."""
-
+"""HTTP application and routes."""
diff --git a/src/agentci/api/app.py b/src/agentci/api/app.py
new file mode 100644
index 0000000..a2cad02
--- /dev/null
+++ b/src/agentci/api/app.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from fastapi import FastAPI
+
+from agentci.api.errors import register_error_handlers
+from agentci.api.lifespan import create_lifespan
+from agentci.api.routes.health import router as health_router
+from agentci.api.routes.webhook import router as webhook_router
+from agentci.config.settings import Settings
+
+
+def create_app(settings: Settings | None = None) -> FastAPI:
+ selected_settings = settings or Settings()
+ app = FastAPI(
+ title="Agent CI",
+ version="0.1.0",
+ lifespan=create_lifespan(selected_settings),
+ )
+ app.include_router(health_router)
+ app.include_router(webhook_router)
+ register_error_handlers(app)
+ return app
+
+
+app = create_app()
diff --git a/src/agentci/api/dependencies.py b/src/agentci/api/dependencies.py
new file mode 100644
index 0000000..875e883
--- /dev/null
+++ b/src/agentci/api/dependencies.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import Depends, Request
+
+from agentci.application.runtime import Runtime
+
+
+def get_runtime(request: Request) -> Runtime:
+ return request.app.state.runtime
+
+
+type RuntimeDependency = Annotated[Runtime, Depends(get_runtime)]
diff --git a/src/agentci/api/errors.py b/src/agentci/api/errors.py
new file mode 100644
index 0000000..189456a
--- /dev/null
+++ b/src/agentci/api/errors.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import logging
+
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
+
+log = logging.getLogger(__name__)
+
+
+async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
+ log.exception(
+ "unhandled HTTP request failure",
+ extra={
+ "operation": "http.request",
+ "method": request.method,
+ "path": request.url.path,
+ "error_message": str(exc),
+ "status_code": 500,
+ },
+ )
+ return JSONResponse(
+ status_code=500,
+ content={"detail": "Internal server error. See service logs for diagnostics."},
+ )
+
+
+def register_error_handlers(app: FastAPI) -> None:
+ app.exception_handler(Exception)(unhandled_error)
diff --git a/src/agentci/api/lifespan.py b/src/agentci/api/lifespan.py
new file mode 100644
index 0000000..f7e066e
--- /dev/null
+++ b/src/agentci/api/lifespan.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import AsyncGenerator, Callable
+from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
+
+from fastapi import FastAPI
+
+from agentci.application.runtime import build_runtime
+from agentci.config.settings import Settings
+from agentci.observability.logging import configure_logging
+
+log = logging.getLogger(__name__)
+Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
+
+
+def create_lifespan(settings: Settings) -> Lifespan:
+ @asynccontextmanager
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
+ configure_logging()
+ log.info("service startup started", extra={"operation": "service.startup"})
+ try:
+ runtime = await build_runtime(settings)
+ except Exception:
+ log.exception("service startup failed", extra={"operation": "service.startup"})
+ raise
+ app.state.runtime = runtime
+ stop = asyncio.Event()
+ worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
+ try:
+ log.info("service startup completed", extra={"operation": "service.startup"})
+ yield
+ finally:
+ log.info("service shutdown started", extra={"operation": "service.shutdown"})
+ stop.set()
+ worker_task.cancel()
+ try:
+ with suppress(asyncio.CancelledError):
+ await worker_task
+ finally:
+ await runtime.close()
+ log.info("service shutdown completed", extra={"operation": "service.shutdown"})
+
+ return lifespan
diff --git a/src/agentci/api/routes/__init__.py b/src/agentci/api/routes/__init__.py
new file mode 100644
index 0000000..7595942
--- /dev/null
+++ b/src/agentci/api/routes/__init__.py
@@ -0,0 +1 @@
+"""FastAPI route modules."""
diff --git a/src/agentci/api/health.py b/src/agentci/api/routes/health.py
similarity index 61%
rename from src/agentci/api/health.py
rename to src/agentci/api/routes/health.py
index 796ae0d..5b5eb0d 100644
--- a/src/agentci/api/health.py
+++ b/src/agentci/api/routes/health.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from fastapi import APIRouter, Request, Response, status
+from fastapi import APIRouter, Response, status
+
+from agentci.api.dependencies import RuntimeDependency
router = APIRouter()
@@ -11,8 +13,8 @@ async def live() -> dict[str, str]:
@router.get("/health/ready")
-async def ready(request: Request, response: Response) -> dict[str, str]:
- if not await request.app.state.container.opencode.ready():
+async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]:
+ if not await runtime.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "opencode provider is not connected"}
return {"status": "ready"}
diff --git a/src/agentci/api/webhook.py b/src/agentci/api/routes/webhook.py
similarity index 52%
rename from src/agentci/api/webhook.py
rename to src/agentci/api/routes/webhook.py
index 2e7bdbb..d08cbbf 100644
--- a/src/agentci/api/webhook.py
+++ b/src/agentci/api/routes/webhook.py
@@ -1,30 +1,28 @@
from __future__ import annotations
-import hashlib
-import hmac
import json
import logging
-from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response, status
-from agentci.domain.models import CommandEvent
+from agentci.api.dependencies import RuntimeDependency
+from agentci.application.runtime import Runtime
+from agentci.engine.model import IncomingCommand
+from agentci.integrations.gitea.webhooks import (
+ SUPPORTED_EVENTS,
+ incoming_command_from_payload,
+ valid_signature,
+)
router = APIRouter()
log = logging.getLogger(__name__)
-SUPPORTED_EVENTS = {
- "issue_comment",
- "pull_request_comment",
- "pull_request_review_comment",
-}
@router.post("/webhooks/gitea")
-async def webhook(request: Request) -> Response:
- container = request.app.state.container
+async def webhook(request: Request, runtime: RuntimeDependency) -> Response:
body = await request.body()
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(
"webhook signature rejected",
extra={"operation": "webhook.verify", "path": request.url.path},
@@ -41,29 +39,26 @@ async def webhook(request: Request) -> Response:
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
payload = json.loads(body)
- event = _event_from_payload(
+ if not isinstance(payload, dict):
+ raise ValueError("Webhook payload must be a JSON object")
+ event = incoming_command_from_payload(
request.headers.get("X-Gitea-Delivery", ""),
payload,
)
- except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ except (KeyError, TypeError, ValueError) as exc:
log.warning(
"webhook payload rejected",
extra={"operation": "webhook.parse", "stage": event_name},
exc_info=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})
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:
- expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
- 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}
if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -71,45 +66,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
log.info("agent command received", extra=extra)
try:
- result = await container.state_machine.receive(event)
+ result = await runtime.repository.accept(event)
except Exception:
log.exception("could not persist command", extra=extra)
raise
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)
log.info(
"agent command persisted",
extra={
**extra,
- "job_id": result.state.id,
- "receive_sequence": result.state.receive_sequence,
+ "job_id": result.job.id,
+ "receive_sequence": result.job.receive_sequence,
},
)
return Response(status_code=status.HTTP_202_ACCEPTED)
-
-
-def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEvent | None:
- if payload.get("action") != "created":
- return None
- comment = payload["comment"]
- repository = payload["repository"]
- owner = repository["owner"]
- owner_name = owner.get("login") or owner.get("username") or owner["name"]
- pull = payload.get("pull_request")
- is_pull = bool(payload.get("is_pull") or pull)
- issue = payload.get("issue")
- target = pull or issue
- if target is None:
- raise ValueError("Comment payload has no issue or pull request")
- number = int(target["number"])
- return CommandEvent(
- delivery_id=delivery_id,
- comment_id=int(comment["id"]),
- repo_owner=owner_name,
- repo_name=repository["name"],
- issue_number=number,
- pr_number=number if is_pull else None,
- requester=comment["user"]["login"],
- body=comment.get("body") or "",
- )
diff --git a/src/agentci/app.py b/src/agentci/app.py
deleted file mode 100644
index fa2f292..0000000
--- a/src/agentci/app.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import logging
-from collections.abc import AsyncIterator
-from contextlib import asynccontextmanager, suppress
-
-from fastapi import FastAPI, Request
-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.container import build_container
-from agentci.logging import configure_logging
-
-log = logging.getLogger(__name__)
-
-def create_app(settings: Settings | None = None) -> FastAPI:
- selected_settings = settings or Settings()
-
- @asynccontextmanager
- async def lifespan(app: FastAPI) -> AsyncIterator[None]:
- configure_logging()
- log.info("service startup started", extra={"operation": "service.startup"})
- try:
- container = await build_container(selected_settings)
- except Exception:
- log.exception("service startup failed", extra={"operation": "service.startup"})
- raise
- app.state.container = container
- stop = asyncio.Event()
- worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker")
- try:
- log.info("service startup completed", extra={"operation": "service.startup"})
- yield
- finally:
- log.info("service shutdown started", extra={"operation": "service.shutdown"})
- stop.set()
- worker_task.cancel()
- try:
- with suppress(asyncio.CancelledError):
- await worker_task
- finally:
- await container.close()
- log.info("service shutdown completed", extra={"operation": "service.shutdown"})
-
- app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
- app.include_router(health_router)
- app.include_router(webhook_router)
-
- @app.exception_handler(Exception)
- async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
- log.exception(
- "unhandled HTTP request failure",
- extra={
- "operation": "http.request",
- "method": request.method,
- "path": request.url.path,
- "status_code": 500,
- },
- )
- return JSONResponse(
- status_code=500,
- content={"detail": "Internal server error. See service logs for diagnostics."},
- )
-
- return app
-
-
-app = create_app()
diff --git a/src/agentci/application/__init__.py b/src/agentci/application/__init__.py
new file mode 100644
index 0000000..858c2a1
--- /dev/null
+++ b/src/agentci/application/__init__.py
@@ -0,0 +1 @@
+"""Application composition and durable orchestration."""
diff --git a/src/agentci/application/runtime.py b/src/agentci/application/runtime.py
new file mode 100644
index 0000000..f2f29af
--- /dev/null
+++ b/src/agentci/application/runtime.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from agentci.application.worker.runner import Worker
+from agentci.config.settings import Settings
+from agentci.engine.repository import Repository
+from agentci.integrations.development import DevelopmentEnvironment
+from agentci.integrations.git import Git
+from agentci.integrations.gitea.client import Gitea
+from agentci.integrations.opencode.client import OpenCode
+from agentci.prompts.library import PromptLibrary
+from agentci.workflows.services import WorkflowServices
+
+log = logging.getLogger(__name__)
+
+@dataclass(frozen=True)
+class Runtime:
+ settings: Settings
+ repository: Repository
+ gitea: Gitea
+ git: Git
+ opencode: OpenCode
+ worker: Worker
+
+ async def close(self) -> None:
+ log.info("runtime shutdown started", extra={"operation": "runtime.close"})
+ try:
+ await self.opencode.close()
+ finally:
+ await self.gitea.close()
+ log.info("runtime shutdown completed", extra={"operation": "runtime.close"})
+
+
+async def build_runtime(settings: Settings) -> Runtime:
+ log.info("runtime initialization started", extra={"operation": "runtime.build"})
+ settings.data_dir.mkdir(parents=True, exist_ok=True)
+ settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
+ repository = Repository(settings.database_path)
+ await repository.initialize()
+ gitea = Gitea(settings.gitea_url, settings.gitea_token)
+ git = Git(
+ gitea_url=settings.gitea_url,
+ username=settings.bot_username,
+ token=settings.gitea_token,
+ askpass_path=settings.askpass_path,
+ commit_name=settings.bot_name,
+ commit_email=settings.bot_email,
+ )
+ prompts = PromptLibrary()
+ opencode = OpenCode(
+ base_url=settings.opencode_url,
+ username=settings.opencode_server_username,
+ password=settings.opencode_server_password,
+ schemas_dir=prompts.schemas_dir,
+ 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 = DevelopmentEnvironment(
+ scripts=settings.install_scripts,
+ 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,
+ )
+ services = WorkflowServices(
+ settings=settings,
+ repository=repository,
+ gitea=gitea,
+ git=git,
+ opencode=opencode,
+ prompts=prompts,
+ development=development,
+ )
+ worker = Worker(
+ repository=repository,
+ gitea=gitea,
+ opencode=opencode,
+ services=services,
+ poll_seconds=settings.worker_poll_seconds,
+ max_concurrent_jobs=settings.max_concurrent_jobs,
+ workspaces_dir=settings.workspaces_dir,
+ bot_username=settings.bot_username,
+ )
+ runtime = Runtime(settings, repository, gitea, git, opencode, worker)
+ log.info("runtime initialization completed", extra={"operation": "runtime.build"})
+ return runtime
diff --git a/src/agentci/application/worker/__init__.py b/src/agentci/application/worker/__init__.py
new file mode 100644
index 0000000..36a41e6
--- /dev/null
+++ b/src/agentci/application/worker/__init__.py
@@ -0,0 +1 @@
+"""Durable task runner and task handlers."""
diff --git a/src/agentci/application/worker/authorization.py b/src/agentci/application/worker/authorization.py
new file mode 100644
index 0000000..4ab08fd
--- /dev/null
+++ b/src/agentci/application/worker/authorization.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from agentci.engine.events import PermissionDenied, PermissionGranted
+from agentci.engine.model import Job, JobStatus, Task
+from agentci.engine.repository import Repository
+from agentci.integrations.gitea.client import Gitea
+
+
+async def authorize_job(
+ *, task: Task, job: Job, repository: Repository, gitea: Gitea
+) -> None:
+ if job.status is not JobStatus.RECEIVED:
+ return
+ permitted = await gitea.has_write_permission(job.repo_owner, job.repo_name, job.requester)
+ event = PermissionGranted(job_id=job.id) if permitted else PermissionDenied(job_id=job.id)
+ outcome = "permission-granted" if permitted else "permission-denied"
+ await repository.apply(f"task:{task.id}:{outcome}", event)
diff --git a/src/agentci/application/worker/comments.py b/src/agentci/application/worker/comments.py
new file mode 100644
index 0000000..c8ed505
--- /dev/null
+++ b/src/agentci/application/worker/comments.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from agentci.engine.events import CommentLinked
+from agentci.engine.model import Job, Task
+from agentci.engine.reducer import render_job_comment
+from agentci.engine.repository import Repository
+from agentci.integrations.gitea.client import Gitea
+
+
+async def reconcile_comment(
+ *,
+ task: Task,
+ job: Job,
+ repository: Repository,
+ gitea: Gitea,
+ bot_username: str,
+) -> None:
+ latest = await repository.get_job(job.id)
+ if latest is None:
+ return
+ body = render_job_comment(latest)
+ comment_id = latest.accepted_comment_id
+ if comment_id is not None and await gitea.update_comment(
+ latest.repo_owner, latest.repo_name, comment_id, body
+ ):
+ return
+ marker = f""
+ matches = sorted(
+ comment.id
+ for comment in await gitea.issue_comments(
+ latest.repo_owner, latest.repo_name, latest.issue_number
+ )
+ if comment.body.startswith(marker)
+ and comment.author.casefold() == bot_username.casefold()
+ )
+ if matches:
+ comment_id = matches[0]
+ else:
+ comment_id = await gitea.create_comment(
+ latest.repo_owner, latest.repo_name, latest.issue_number, body
+ )
+ await repository.apply(
+ f"task:{task.id}:comment:{comment_id}",
+ CommentLinked(job_id=latest.id, comment_id=comment_id),
+ )
+ await gitea.update_comment(
+ latest.repo_owner, latest.repo_name, comment_id, body
+ )
diff --git a/src/agentci/application/worker/errors.py b/src/agentci/application/worker/errors.py
new file mode 100644
index 0000000..e8d236b
--- /dev/null
+++ b/src/agentci/application/worker/errors.py
@@ -0,0 +1,6 @@
+from __future__ import annotations
+
+
+def safe_error(error: Exception) -> str:
+ message = " ".join(str(error).split())
+ return f"{type(error).__name__}: {message}"[:1000]
diff --git a/src/agentci/application/worker/execution.py b/src/agentci/application/worker/execution.py
new file mode 100644
index 0000000..b0e73b7
--- /dev/null
+++ b/src/agentci/application/worker/execution.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from agentci.application.worker.errors import safe_error
+from agentci.engine.events import JobCompleted, JobFailed, JobStarted, ServiceRestarted
+from agentci.engine.events import JobRejected as RejectedEvent
+from agentci.engine.model import Job, JobStatus, Task
+from agentci.engine.repository import Repository
+from agentci.engine.run import JobRun
+from agentci.workflows.dispatch import dispatch
+from agentci.workflows.render import JobRejected
+from agentci.workflows.services import WorkflowServices
+
+
+async def execute_job(
+ *, task: Task, job: Job, repository: Repository, services: WorkflowServices
+) -> None:
+ if job.status is JobStatus.RUNNING:
+ await repository.apply(
+ f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
+ )
+ return
+ if job.status is not JobStatus.QUEUED:
+ return
+ result = await repository.apply(f"task:{task.id}:started", JobStarted(job_id=job.id))
+ running = result.job
+ run = JobRun(repository, job.id, task.id)
+ try:
+ body = await dispatch(running, run, services)
+ except JobRejected as exc:
+ await repository.apply(
+ f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
+ )
+ except Exception as exc:
+ latest = await repository.get_job(job.id)
+ stage = latest.stage if latest else running.stage
+ await repository.apply(
+ f"task:{task.id}:failed",
+ JobFailed(job_id=job.id, error=safe_error(exc), stage=stage),
+ )
+ else:
+ await repository.apply(
+ f"task:{task.id}:completed",
+ JobCompleted(
+ job_id=job.id,
+ comment_body=body or "Agent job completed.",
+ ),
+ )
diff --git a/src/agentci/application/worker/recovery.py b/src/agentci/application/worker/recovery.py
new file mode 100644
index 0000000..7c7d32b
--- /dev/null
+++ b/src/agentci/application/worker/recovery.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from agentci.engine.events import ServiceRestarted
+from agentci.engine.repository import Repository
+
+
+async def recover_jobs(repository: Repository) -> None:
+ await repository.recover_tasks()
+ for job in await repository.running_jobs():
+ await repository.apply(
+ f"recovery:{job.id}:service-restarted",
+ ServiceRestarted(job_id=job.id),
+ )
diff --git a/src/agentci/application/worker/runner.py b/src/agentci/application/worker/runner.py
new file mode 100644
index 0000000..73d2e2e
--- /dev/null
+++ b/src/agentci/application/worker/runner.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from contextlib import suppress
+from pathlib import Path
+
+from agentci.application.worker.authorization import authorize_job
+from agentci.application.worker.comments import reconcile_comment
+from agentci.application.worker.errors import safe_error
+from agentci.application.worker.execution import execute_job
+from agentci.application.worker.recovery import recover_jobs
+from agentci.application.worker.sessions import abort_job_sessions
+from agentci.engine.model import Job, QueueName, Task, TaskKind
+from agentci.engine.repository import Repository
+from agentci.integrations.gitea.client import Gitea
+from agentci.integrations.opencode.client import OpenCode
+from agentci.workflows.services import WorkflowServices
+
+log = logging.getLogger(__name__)
+
+
+class Worker:
+ def __init__(
+ self,
+ *,
+ repository: Repository,
+ gitea: Gitea,
+ opencode: OpenCode,
+ services: WorkflowServices,
+ poll_seconds: float,
+ max_concurrent_jobs: int,
+ workspaces_dir: Path,
+ bot_username: str,
+ ) -> None:
+ self.repository = repository
+ self.gitea = gitea
+ self.opencode = opencode
+ self.services = services
+ self.poll_seconds = poll_seconds
+ self.max_concurrent_jobs = max_concurrent_jobs
+ self.workspaces_dir = workspaces_dir
+ self.bot_username = bot_username
+
+ async def run(self, stop: asyncio.Event) -> None:
+ await self._recover()
+ await asyncio.gather(
+ self._loop(QueueName.CONTROL, stop),
+ *(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
+ )
+
+ async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
+ while not stop.is_set():
+ if queue is QueueName.JOBS and not await self.opencode.ready():
+ await self._wait(stop)
+ continue
+ task = await self.repository.claim_task(queue)
+ if task is None:
+ await self._wait(stop)
+ continue
+ try:
+ await self._handle(task)
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ log.exception(
+ "listener failed",
+ extra={
+ "task_id": task.id,
+ "listener": task.kind.value,
+ "queue": queue.value,
+ },
+ )
+ await self.repository.retry_task(task.id, task.attempts, safe_error(exc))
+ else:
+ await self.repository.complete_task(task.id)
+
+ async def _handle(self, task: Task) -> None:
+ job = await self.repository.get_job(task.job_id)
+ if job is None:
+ return
+ match task.kind:
+ case TaskKind.AUTHORIZE:
+ await self._authorize(task, job)
+ case TaskKind.EXECUTE:
+ await self._execute(task, job)
+ case TaskKind.RECONCILE_COMMENT:
+ await self._reconcile(task, job)
+ case TaskKind.FAIL_WORKFLOW:
+ await self.repository.fail_job_workflow(job.id)
+ case TaskKind.ABORT_SESSIONS:
+ await self._abort_job_sessions(job)
+ case _:
+ raise RuntimeError(f"Unknown task kind {task.kind}")
+
+ async def _authorize(self, task: Task, job: Job) -> None:
+ await authorize_job(
+ task=task,
+ job=job,
+ repository=self.repository,
+ gitea=self.gitea,
+ )
+
+ async def _execute(self, task: Task, job: Job) -> None:
+ await execute_job(
+ task=task,
+ job=job,
+ repository=self.repository,
+ services=self.services,
+ )
+
+ async def _reconcile(self, task: Task, job: Job) -> None:
+ await reconcile_comment(
+ task=task,
+ job=job,
+ repository=self.repository,
+ gitea=self.gitea,
+ bot_username=self.bot_username,
+ )
+
+ async def _recover(self) -> None:
+ await recover_jobs(self.repository)
+
+ async def _abort_job_sessions(self, job: Job) -> None:
+ await abort_job_sessions(
+ job=job,
+ repository=self.repository,
+ opencode=self.opencode,
+ workspaces_dir=self.workspaces_dir,
+ )
+
+ async def _wait(self, stop: asyncio.Event) -> None:
+ with suppress(TimeoutError):
+ await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
diff --git a/src/agentci/application/worker/sessions.py b/src/agentci/application/worker/sessions.py
new file mode 100644
index 0000000..47f367c
--- /dev/null
+++ b/src/agentci/application/worker/sessions.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from agentci.engine.model import Job
+from agentci.engine.repository import Repository
+from agentci.integrations.opencode.client import OpenCode
+
+
+async def abort_job_sessions(
+ *,
+ job: Job,
+ repository: Repository,
+ opencode: OpenCode,
+ workspaces_dir: Path,
+) -> None:
+ sessions: set[tuple[str, Path]] = set()
+ workflow = await repository.get_workflow(job.workflow_id) if job.workflow_id else None
+ if workflow:
+ sessions.update(
+ (session, workflow.workspace_path)
+ for session in (workflow.primary_session_id, workflow.reviewer_session_id)
+ if session
+ )
+ elif job.runtime_session_id:
+ sessions.add(
+ (job.runtime_session_id, workspaces_dir / f"fix-{job.id}" / "repo")
+ )
+ for session, workspace in sessions:
+ await opencode.abort(session, workspace)
diff --git a/src/agentci/config/__init__.py b/src/agentci/config/__init__.py
new file mode 100644
index 0000000..30025d0
--- /dev/null
+++ b/src/agentci/config/__init__.py
@@ -0,0 +1 @@
+"""Service configuration."""
diff --git a/src/agentci/config.py b/src/agentci/config/settings.py
similarity index 100%
rename from src/agentci/config.py
rename to src/agentci/config/settings.py
diff --git a/src/agentci/container.py b/src/agentci/container.py
deleted file mode 100644
index 09f502d..0000000
--- a/src/agentci/container.py
+++ /dev/null
@@ -1,104 +0,0 @@
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-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.prompts import PromptLibrary
-from agentci.state_machine import StateMachine
-from agentci.worker import Worker
-from agentci.workflows.common import Dependencies
-from agentci.workflows.context import ContextBuilder
-from agentci.workflows.dispatcher import Dispatcher
-
-log = logging.getLogger(__name__)
-
-@dataclass(frozen=True)
-class Container:
- settings: Settings
- storage: Storage
- gitea: GiteaClient
- git: GitClient
- opencode: OpenCodeClient
- state_machine: StateMachine
- worker: Worker
-
- async def close(self) -> None:
- log.info("container shutdown started", extra={"operation": "container.close"})
- await self.opencode.close()
- await self.gitea.close()
- log.info("container shutdown completed", extra={"operation": "container.close"})
-
-
-async def build_container(settings: Settings) -> Container:
- log.info("container initialization started", extra={"operation": "container.build"})
- package_dir = Path(__file__).parent
- settings.data_dir.mkdir(parents=True, exist_ok=True)
- settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
- storage = Storage(settings.database_path, package_dir / "migrations")
- await storage.initialize()
- state_machine = StateMachine(storage)
- gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
- git = GitClient(
- gitea_url=settings.gitea_url,
- username=settings.bot_username,
- token=settings.gitea_token,
- askpass_path=settings.askpass_path,
- commit_name=settings.bot_name,
- commit_email=settings.bot_email,
- )
- opencode = OpenCodeClient(
- base_url=settings.opencode_url,
- username=settings.opencode_server_username,
- password=settings.opencode_server_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,
- )
- prompts = PromptLibrary()
- context = ContextBuilder(gitea, storage)
- development = DevelopmentEnvironment(
- scripts=settings.install_scripts,
- 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,
- )
- dependencies = Dependencies(
- settings=settings,
- storage=storage,
- gitea=gitea,
- git=git,
- opencode=opencode,
- prompts=prompts,
- context=context,
- development=development,
- )
- dispatcher = Dispatcher(dependencies)
- worker = Worker(
- storage=storage,
- state_machine=state_machine,
- gitea=gitea,
- opencode=opencode,
- dispatcher=dispatcher,
- poll_seconds=settings.worker_poll_seconds,
- max_concurrent_jobs=settings.max_concurrent_jobs,
- workspaces_dir=settings.workspaces_dir,
- bot_username=settings.bot_username,
- )
- container = Container(settings, storage, gitea, git, opencode, state_machine, worker)
- log.info("container initialization completed", extra={"operation": "container.build"})
- return container
diff --git a/src/agentci/domain/__init__.py b/src/agentci/domain/__init__.py
deleted file mode 100644
index 3ed7f96..0000000
--- a/src/agentci/domain/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-"""Domain types and policies."""
-
diff --git a/src/agentci/domain/state_machine.py b/src/agentci/domain/state_machine.py
deleted file mode 100644
index 9c6fd5d..0000000
--- a/src/agentci/domain/state_machine.py
+++ /dev/null
@@ -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""
- 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}"
diff --git a/src/agentci/engine/__init__.py b/src/agentci/engine/__init__.py
new file mode 100644
index 0000000..d83cb44
--- /dev/null
+++ b/src/agentci/engine/__init__.py
@@ -0,0 +1 @@
+"""Durable job and workflow engine."""
diff --git a/src/agentci/engine/_sqlite.py b/src/agentci/engine/_sqlite.py
new file mode 100644
index 0000000..a1c78c9
--- /dev/null
+++ b/src/agentci/engine/_sqlite.py
@@ -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,
+ )
diff --git a/src/agentci/domain/commands.py b/src/agentci/engine/commands.py
similarity index 95%
rename from src/agentci/domain/commands.py
rename to src/agentci/engine/commands.py
index 877d1eb..154bee2 100644
--- a/src/agentci/domain/commands.py
+++ b/src/agentci/engine/commands.py
@@ -2,7 +2,7 @@ from __future__ import annotations
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]*))?$")
diff --git a/src/agentci/domain/events.py b/src/agentci/engine/events.py
similarity index 97%
rename from src/agentci/domain/events.py
rename to src/agentci/engine/events.py
index 9bd22c1..e880be6 100644
--- a/src/agentci/domain/events.py
+++ b/src/agentci/engine/events.py
@@ -4,7 +4,7 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
-from agentci.domain.models import Workflow
+from agentci.engine.model import Workflow
class Event(BaseModel):
diff --git a/src/agentci/domain/models.py b/src/agentci/engine/model.py
similarity index 67%
rename from src/agentci/domain/models.py
rename to src/agentci/engine/model.py
index 809e387..01e9f51 100644
--- a/src/agentci/domain/models.py
+++ b/src/agentci/engine/model.py
@@ -4,8 +4,6 @@ from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
-from pydantic import BaseModel, Field
-
class CommandName(StrEnum):
PLAN = "plan"
@@ -44,43 +42,17 @@ class WorkflowStatus(StrEnum):
FAILED = "failed"
-class ReviewSeverity(StrEnum):
- BLOCKING = "blocking"
- MAJOR = "major"
- MINOR = "minor"
+class TaskKind(StrEnum):
+ AUTHORIZE = "authorize"
+ EXECUTE = "execute"
+ RECONCILE_COMMENT = "reconcile_comment"
+ FAIL_WORKFLOW = "fail_workflow"
+ ABORT_SESSIONS = "abort_sessions"
-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
- )
+class QueueName(StrEnum):
+ CONTROL = "control"
+ JOBS = "jobs"
@dataclass(frozen=True)
@@ -90,7 +62,7 @@ class ParsedCommand:
@dataclass(frozen=True)
-class CommandEvent:
+class IncomingCommand:
delivery_id: str
comment_id: int
repo_owner: str
@@ -110,26 +82,35 @@ class CommandEvent:
return f"{self.repo_owner}/{self.repo_name}:{target}"
-@dataclass
+@dataclass(frozen=True)
class Job:
id: str
- kind: JobKind
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
- message: 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
- status: JobStatus = JobStatus.QUEUED
- stage: str = "queued"
- accepted_comment_id: int | 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:
id: str
kind: WorkflowKind
@@ -146,3 +127,19 @@ class Workflow:
artifact: str | None = None
review_json: str | None = None
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
diff --git a/src/agentci/engine/reducer.py b/src/agentci/engine/reducer.py
new file mode 100644
index 0000000..743feb3
--- /dev/null
+++ b/src/agentci/engine/reducer.py
@@ -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""
+ 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}"
diff --git a/src/agentci/engine/repository.py b/src/agentci/engine/repository.py
new file mode 100644
index 0000000..5866655
--- /dev/null
+++ b/src/agentci/engine/repository.py
@@ -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()
diff --git a/src/agentci/engine/run.py b/src/agentci/engine/run.py
new file mode 100644
index 0000000..9799fec
--- /dev/null
+++ b/src/agentci/engine/run.py
@@ -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)
diff --git a/src/agentci/integrations/__init__.py b/src/agentci/integrations/__init__.py
new file mode 100644
index 0000000..8761bc1
--- /dev/null
+++ b/src/agentci/integrations/__init__.py
@@ -0,0 +1 @@
+"""External service and process integrations."""
diff --git a/src/agentci/adapters/codegraph.py b/src/agentci/integrations/codegraph.py
similarity index 99%
rename from src/agentci/adapters/codegraph.py
rename to src/agentci/integrations/codegraph.py
index bcdcf23..4a3471f 100644
--- a/src/agentci/adapters/codegraph.py
+++ b/src/agentci/integrations/codegraph.py
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
pass
-class CodeGraphClient:
+class CodeGraph:
async def prepare(self, workspace: Path) -> None:
self._exclude_index(workspace)
index = workspace / ".codegraph" / "codegraph.db"
diff --git a/src/agentci/adapters/development.py b/src/agentci/integrations/development.py
similarity index 100%
rename from src/agentci/adapters/development.py
rename to src/agentci/integrations/development.py
diff --git a/src/agentci/adapters/git.py b/src/agentci/integrations/git.py
similarity index 92%
rename from src/agentci/adapters/git.py
rename to src/agentci/integrations/git.py
index 8bd442d..d2929f3 100644
--- a/src/agentci/adapters/git.py
+++ b/src/agentci/integrations/git.py
@@ -13,7 +13,7 @@ class GitError(RuntimeError):
pass
-class GitClient:
+class Git:
def __init__(
self,
*,
@@ -31,9 +31,6 @@ class GitClient:
self.commit_name = commit_name
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(
self,
owner: str,
@@ -47,7 +44,7 @@ class GitClient:
"--branch",
branch,
"--single-branch",
- self.clone_url(owner, repo),
+ f"{self.gitea_url}/{owner}/{repo}.git",
str(destination),
cwd=destination.parent,
authenticated=True,
@@ -85,6 +82,7 @@ class GitClient:
"-m",
message,
cwd=workspace,
+ command_name="commit",
)
return await self.current_sha(workspace)
@@ -101,8 +99,10 @@ class GitClient:
*args: str,
cwd: Path,
authenticated: bool = False,
+ command_name: str | None = None,
) -> str:
- operation = f"git.{args[0]}"
+ command_name = command_name or args[0]
+ operation = f"git.{command_name}"
started = monotonic()
log.info("git step started", extra={"operation": operation})
environment = os.environ.copy()
@@ -130,14 +130,14 @@ class GitClient:
"git step could not start",
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:
detail = stderr.decode(errors="replace").strip()
log.error(
"git step failed",
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(
"git step completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
diff --git a/src/agentci/integrations/gitea/__init__.py b/src/agentci/integrations/gitea/__init__.py
new file mode 100644
index 0000000..4ce406f
--- /dev/null
+++ b/src/agentci/integrations/gitea/__init__.py
@@ -0,0 +1 @@
+"""Gitea API and webhook integration."""
diff --git a/src/agentci/adapters/gitea.py b/src/agentci/integrations/gitea/client.py
similarity index 93%
rename from src/agentci/adapters/gitea.py
rename to src/agentci/integrations/gitea/client.py
index e2b3572..001922c 100644
--- a/src/agentci/adapters/gitea.py
+++ b/src/agentci/integrations/gitea/client.py
@@ -7,12 +7,7 @@ from typing import Any
import httpx
-from agentci.adapters.gitea_models import (
- CommentInfo,
- IssueInfo,
- PullRequestInfo,
- RepositoryInfo,
-)
+from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
log = logging.getLogger(__name__)
@@ -21,14 +16,22 @@ class GiteaError(RuntimeError):
pass
-class GiteaClient:
- def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None:
+class Gitea:
+ def __init__(
+ self,
+ base_url: str,
+ token: str,
+ *,
+ retries: int = 3,
+ transport: httpx.AsyncBaseTransport | None = None,
+ ) -> None:
self.base_url = base_url.rstrip("/")
self.retries = retries
self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
timeout=30,
+ transport=transport,
)
async def close(self) -> None:
@@ -41,14 +44,9 @@ class GiteaClient:
permission = str(response.json().get("permission", "")).lower()
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()
- return RepositoryInfo(
- owner=owner,
- name=repo,
- full_name=data.get("full_name", f"{owner}/{repo}"),
- default_branch=data["default_branch"],
- )
+ return str(data["default_branch"])
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
diff --git a/src/agentci/adapters/gitea_models.py b/src/agentci/integrations/gitea/models.py
similarity index 83%
rename from src/agentci/adapters/gitea_models.py
rename to src/agentci/integrations/gitea/models.py
index 2cb8bf6..2b4b9b6 100644
--- a/src/agentci/adapters/gitea_models.py
+++ b/src/agentci/integrations/gitea/models.py
@@ -3,14 +3,6 @@ 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
@@ -43,4 +35,3 @@ class PullRequestInfo:
@property
def is_open(self) -> bool:
return self.state == "open" and not self.merged
-
diff --git a/src/agentci/integrations/gitea/webhooks.py b/src/agentci/integrations/gitea/webhooks.py
new file mode 100644
index 0000000..4fee9a4
--- /dev/null
+++ b/src/agentci/integrations/gitea/webhooks.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+from typing import Any
+
+from agentci.engine.model import IncomingCommand
+
+SUPPORTED_EVENTS = {
+ "issue_comment",
+ "pull_request_comment",
+ "pull_request_review_comment",
+}
+
+
+def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
+ expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
+ return bool(signature) and hmac.compare_digest(expected, signature)
+
+
+def incoming_command_from_payload(
+ delivery_id: str, payload: dict[str, Any]
+) -> IncomingCommand | None:
+ if payload.get("action") != "created":
+ return None
+ comment = payload["comment"]
+ repository = payload["repository"]
+ owner = repository["owner"]
+ owner_name = owner.get("login") or owner.get("username") or owner["name"]
+ pull = payload.get("pull_request")
+ is_pull = bool(payload.get("is_pull") or pull)
+ issue = payload.get("issue")
+ target = pull or issue
+ if target is None:
+ raise ValueError("Comment payload has no issue or pull request")
+ number = int(target["number"])
+ return IncomingCommand(
+ delivery_id=delivery_id,
+ comment_id=int(comment["id"]),
+ repo_owner=owner_name,
+ repo_name=repository["name"],
+ issue_number=number,
+ pr_number=number if is_pull else None,
+ requester=comment["user"]["login"],
+ body=comment.get("body") or "",
+ )
diff --git a/src/agentci/integrations/opencode/__init__.py b/src/agentci/integrations/opencode/__init__.py
new file mode 100644
index 0000000..7334d1d
--- /dev/null
+++ b/src/agentci/integrations/opencode/__init__.py
@@ -0,0 +1 @@
+"""OpenCode API integration."""
diff --git a/src/agentci/adapters/opencode.py b/src/agentci/integrations/opencode/client.py
similarity index 80%
rename from src/agentci/adapters/opencode.py
rename to src/agentci/integrations/opencode/client.py
index 98081dd..4ff1a0b 100644
--- a/src/agentci/adapters/opencode.py
+++ b/src/agentci/integrations/opencode/client.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import logging
+from contextlib import suppress
from pathlib import Path
from time import monotonic
from typing import Any, TypeVar
@@ -9,16 +10,9 @@ from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, ValidationError
-from agentci.adapters.codegraph import CodeGraphClient
-from agentci.adapters.opencode_support import (
- api_contract_ready,
- directory_headers,
- elapsed_ms,
- error_message,
- load_schema,
- model_parts,
- models_ready,
-)
+from agentci.integrations.codegraph import CodeGraph
+from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
+from agentci.integrations.opencode.schemas import load_schema
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
@@ -28,7 +22,7 @@ class OpenCodeError(RuntimeError):
pass
-class OpenCodeClient:
+class OpenCode:
def __init__(
self,
*,
@@ -39,7 +33,7 @@ class OpenCodeClient:
health_directory: Path,
required_models: tuple[tuple[str, str | None], ...],
timeout_seconds: int,
- codegraph: CodeGraphClient | None = None,
+ codegraph: CodeGraph | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.schemas_dir = schemas_dir
@@ -48,8 +42,9 @@ class OpenCodeClient:
(*model_parts(model), variant) for model, variant in required_models
}
self._contract_valid: bool | None = None
+ self._readiness_task: asyncio.Task[bool] | None = None
self.timeout_seconds = timeout_seconds
- self.codegraph = codegraph or CodeGraphClient()
+ self.codegraph = codegraph or CodeGraph()
self._active_sessions: dict[str, Path] = {}
self.client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
@@ -59,11 +54,27 @@ class OpenCodeClient:
)
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()):
await self.abort(session_id, workspace, best_effort=True)
await self.client.aclose()
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:
health = await self.client.get("/global/health", timeout=10)
health.raise_for_status()
@@ -85,28 +96,6 @@ class OpenCodeClient:
except (httpx.HTTPError, TypeError, ValueError):
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:
response = await self._request(
"POST",
@@ -143,7 +132,10 @@ class OpenCodeClient:
result_type=result_type,
)
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
finally:
self._active_sessions.pop(session_id, None)
@@ -186,7 +178,6 @@ class OpenCodeClient:
json=payload,
)
except httpx.TimeoutException as exc:
- await self.abort(session_id, workspace)
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
raise OpenCodeError(message) from exc
info = response.get("info")
@@ -213,7 +204,10 @@ class OpenCodeClient:
raise OpenCodeError("OpenCode did not return a valid result")
async def _request(
- self, method: str, path: str, *,
+ self,
+ method: str,
+ path: str,
+ *,
workspace: Path,
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
@@ -248,3 +242,27 @@ class OpenCodeClient:
log.warning("OpenCode session could not be aborted", exc_info=exc)
return
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())}
+
diff --git a/src/agentci/integrations/opencode/readiness.py b/src/agentci/integrations/opencode/readiness.py
new file mode 100644
index 0000000..4f63a59
--- /dev/null
+++ b/src/agentci/integrations/opencode/readiness.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+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
diff --git a/src/agentci/integrations/opencode/schemas.py b/src/agentci/integrations/opencode/schemas.py
new file mode 100644
index 0000000..6995cb5
--- /dev/null
+++ b/src/agentci/integrations/opencode/schemas.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+
+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
diff --git a/src/agentci/observability/__init__.py b/src/agentci/observability/__init__.py
new file mode 100644
index 0000000..f5912d1
--- /dev/null
+++ b/src/agentci/observability/__init__.py
@@ -0,0 +1 @@
+"""Service observability configuration."""
diff --git a/src/agentci/logging.py b/src/agentci/observability/logging.py
similarity index 78%
rename from src/agentci/logging.py
rename to src/agentci/observability/logging.py
index c432bca..c1dd836 100644
--- a/src/agentci/logging.py
+++ b/src/agentci/observability/logging.py
@@ -15,6 +15,7 @@ LOG_FIELDS = (
"method",
"path",
"status_code",
+ "error_message",
"attempt",
"item_count",
"duration_ms",
@@ -37,9 +38,14 @@ class JsonFormatter(logging.Formatter):
try:
return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
- payload["message"] = "Log record could not be serialized"
- payload["exception"] = traceback.format_exc()
- return json.dumps(payload, ensure_ascii=False)
+ fallback = {
+ "timestamp": payload["timestamp"],
+ "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:
diff --git a/src/agentci/prompts/__init__.py b/src/agentci/prompts/__init__.py
index 83d790f..077a3fe 100644
--- a/src/agentci/prompts/__init__.py
+++ b/src/agentci/prompts/__init__.py
@@ -1,14 +1 @@
-from __future__ import annotations
-
-from pathlib import Path
-from string import Template
-
-
-class PromptLibrary:
- def __init__(self, directory: Path | None = None) -> None:
- self.directory = directory or Path(__file__).parent
-
- def render(self, name: str, **values: str) -> str:
- template = Template((self.directory / f"{name}.md").read_text())
- return template.substitute(values)
-
+"""Prompt templates and structured-output contracts."""
diff --git a/src/agentci/prompts/library.py b/src/agentci/prompts/library.py
new file mode 100644
index 0000000..63143e6
--- /dev/null
+++ b/src/agentci/prompts/library.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from pathlib import Path
+from string import Template
+
+
+class PromptLibrary:
+ def __init__(self, directory: Path | None = None) -> None:
+ self.directory = directory or Path(__file__).parent
+
+ def render(self, name: str, **values: str) -> str:
+ template = Template((self.directory / f"{name}.md").read_text())
+ return template.substitute(values)
+
+ @property
+ def schemas_dir(self) -> Path:
+ return self.directory / "schemas"
diff --git a/src/agentci/reporting.py b/src/agentci/reporting.py
deleted file mode 100644
index c424b5b..0000000
--- a/src/agentci/reporting.py
+++ /dev/null
@@ -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()
diff --git a/src/agentci/state_machine.py b/src/agentci/state_machine.py
deleted file mode 100644
index c6d67d0..0000000
--- a/src/agentci/state_machine.py
+++ /dev/null
@@ -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)
diff --git a/src/agentci/worker.py b/src/agentci/worker.py
deleted file mode 100644
index b8d1be9..0000000
--- a/src/agentci/worker.py
+++ /dev/null
@@ -1,220 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import logging
-from contextlib import suppress
-from pathlib import Path
-
-from agentci.adapters.gitea import GiteaClient
-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,
- JobCompleted,
- JobFailed,
- JobStarted,
- PermissionDenied,
- PermissionGranted,
- ServiceRestarted,
-)
-from agentci.domain.events import (
- JobRejected as RejectedEvent,
-)
-from agentci.domain.models import JobStatus
-from agentci.domain.state_machine import JobState, render_job_comment
-from agentci.reporting import JobReporter, bind_reporter, reset_reporter
-from agentci.state_machine import StateMachine
-from agentci.workflows.common import JobRejected
-from agentci.workflows.dispatcher import Dispatcher
-
-log = logging.getLogger(__name__)
-
-
-class Worker:
- def __init__(
- self,
- *,
- storage: Storage,
- state_machine: StateMachine,
- gitea: GiteaClient,
- opencode: OpenCodeClient,
- dispatcher: Dispatcher,
- poll_seconds: float,
- max_concurrent_jobs: int,
- workspaces_dir: Path,
- bot_username: str,
- ) -> None:
- self.storage = storage
- self.host = state_machine
- self.gitea = gitea
- self.opencode = opencode
- self.dispatcher = dispatcher
- self.poll_seconds = poll_seconds
- self.max_concurrent_jobs = max_concurrent_jobs
- self.workspaces_dir = workspaces_dir
- self.bot_username = bot_username
-
- async def run(self, stop: asyncio.Event) -> None:
- await self._recover()
- await asyncio.gather(
- self._loop("control", stop),
- *(self._loop("jobs", stop) for _ in range(self.max_concurrent_jobs)),
- )
-
- async def _loop(self, queue: str, stop: asyncio.Event) -> None:
- while not stop.is_set():
- if queue == "jobs" and not await self.opencode.ready():
- await self._wait(stop)
- continue
- task = await self.storage.claim_task(queue)
- if task is None:
- await self._wait(stop)
- continue
- try:
- await self._handle(task)
- except asyncio.CancelledError:
- raise
- except Exception as exc:
- log.exception(
- "listener failed",
- extra={"task_id": task.id, "listener": task.listener, "queue": queue},
- )
- await self.storage.retry_task(task.id, task.attempts, _safe_error(exc))
- else:
- await self.storage.complete_task(task.id)
-
- async def _handle(self, task: ListenerTask) -> None:
- state = await self.host.get(task.job_id)
- if state is None:
- return
- if task.listener == "authorize":
- await self._authorize(task, state)
- elif task.listener == "execute":
- await self._execute(task, state)
- elif task.listener == "reconcile_comment":
- await self._reconcile(task, state)
- elif task.listener == "fail_workflow":
- await self.storage.fail_job_workflow(state.id)
- elif task.listener == "abort_sessions":
- await self._abort_job_sessions(state)
- else:
- raise RuntimeError(f"Unknown listener {task.listener}")
-
- async def _authorize(self, task: ListenerTask, state: JobState) -> None:
- if state.status is not JobStatus.RECEIVED:
- return
- permitted = await self.gitea.has_write_permission(
- state.repo_owner, state.repo_name, state.requester
- )
- event = (
- PermissionGranted(job_id=state.id)
- if permitted
- else PermissionDenied(job_id=state.id)
- )
- outcome = "permission-granted" if permitted else "permission-denied"
- await self.host.evolve(f"task:{task.id}:{outcome}", event)
-
- async def _execute(self, task: ListenerTask, state: JobState) -> None:
- if state.status is JobStatus.RUNNING:
- await self.host.evolve(
- f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id)
- )
- return
- if state.status is not JobStatus.QUEUED:
- return
- result = await self.host.evolve(
- f"task:{task.id}:started", JobStarted(job_id=state.id)
- )
- running = result.state
- reporter = JobReporter(self.host, state.id, task.id)
- token = bind_reporter(reporter)
- try:
- await self.dispatcher.dispatch(running)
- except JobRejected as exc:
- await self.host.evolve(
- f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc))
- )
- except Exception as exc:
- latest = await self.host.get(state.id)
- stage = latest.stage if latest else running.stage
- await self.host.evolve(
- f"task:{task.id}:failed",
- JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage),
- )
- else:
- await self.host.evolve(
- f"task:{task.id}:completed",
- JobCompleted(
- job_id=state.id,
- comment_body=reporter.final_body or "Agent job completed.",
- ),
- )
- finally:
- reset_reporter(token)
-
- async def _reconcile(self, task: ListenerTask, state: JobState) -> None:
- latest = await self.host.get(state.id)
- if latest is None:
- return
- body = render_job_comment(latest)
- comment_id = latest.accepted_comment_id
- if comment_id is not None and await self.gitea.update_comment(
- latest.repo_owner, latest.repo_name, comment_id, body
- ):
- return
- marker = f""
- matches = sorted(
- comment.id
- for comment in await self.gitea.issue_comments(
- latest.repo_owner, latest.repo_name, latest.issue_number
- )
- if comment.body.startswith(marker)
- and comment.author.casefold() == self.bot_username.casefold()
- )
- if matches:
- comment_id = matches[0]
- else:
- comment_id = await self.gitea.create_comment(
- latest.repo_owner, latest.repo_name, latest.issue_number, body
- )
- await self.host.evolve(
- f"task:{task.id}:comment:{comment_id}",
- CommentLinked(job_id=latest.id, comment_id=comment_id),
- )
- await self.gitea.update_comment(
- latest.repo_owner, latest.repo_name, comment_id, body
- )
-
- async def _recover(self) -> None:
- await self.storage.recover_tasks()
- for state in await self.storage.running_job_states():
- await self.host.evolve(
- f"recovery:{state.id}:service-restarted",
- ServiceRestarted(job_id=state.id),
- )
-
- async def _abort_job_sessions(self, state: JobState) -> None:
- sessions: set[tuple[str, Path]] = set()
- workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None
- if workflow:
- sessions.update(
- (session, workflow.workspace_path)
- for session in (workflow.primary_session_id, workflow.reviewer_session_id)
- if session
- )
- elif state.runtime_session_id:
- sessions.add(
- (state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo")
- )
- for session, workspace in sessions:
- await self.opencode.abort(session, workspace)
-
- async def _wait(self, stop: asyncio.Event) -> None:
- with suppress(TimeoutError):
- await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
-
-
-def _safe_error(error: Exception) -> str:
- message = " ".join(str(error).split())
- return f"{type(error).__name__}: {message}"[:1000]
diff --git a/src/agentci/workflows/__init__.py b/src/agentci/workflows/__init__.py
index 9416311..6fb09d6 100644
--- a/src/agentci/workflows/__init__.py
+++ b/src/agentci/workflows/__init__.py
@@ -1 +1 @@
-"""OpenCode workflow orchestration."""
+"""Functional workflow orchestration."""
diff --git a/src/agentci/workflows/change_set.py b/src/agentci/workflows/change_set.py
deleted file mode 100644
index 7f5bd84..0000000
--- a/src/agentci/workflows/change_set.py
+++ /dev/null
@@ -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"
diff --git a/src/agentci/workflows/code_review.py b/src/agentci/workflows/code_review.py
deleted file mode 100644
index ef0c736..0000000
--- a/src/agentci/workflows/code_review.py
+++ /dev/null
@@ -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
diff --git a/src/agentci/workflows/common.py b/src/agentci/workflows/common.py
deleted file mode 100644
index a8b4e97..0000000
--- a/src/agentci/workflows/common.py
+++ /dev/null
@@ -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"\n{body}"
-
-
-async def finish_job(body: str) -> None:
- reporter().finish(body)
diff --git a/src/agentci/workflows/context.py b/src/agentci/workflows/context.py
index 9f17666..8b8f571 100644
--- a/src/agentci/workflows/context.py
+++ b/src/agentci/workflows/context.py
@@ -1,81 +1,104 @@
from __future__ import annotations
+import asyncio
from typing import Any
-from agentci.adapters.gitea import GiteaClient
-from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo
-from agentci.adapters.storage import Storage
+from agentci.engine.repository import Repository
+from agentci.integrations.gitea.client import Gitea
+from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo
-class ContextBuilder:
- def __init__(self, gitea: GiteaClient, storage: Storage) -> None:
- self.gitea = gitea
- self.storage = storage
+async def build_issue_context(
+ gitea: Gitea,
+ repository: Repository,
+ 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)
- comments = await self.gitea.issue_comments(owner, repo, number)
- operational = await self.storage.operational_comment_ids(owner, repo, number)
- discussion = "\n\n".join(
- _format_comment(comment) for comment in comments if comment.id not in operational
+
+async def build_pull_request_context(
+ gitea: Gitea,
+ owner: str,
+ repo: str,
+ 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 (
- f"Repository: {owner}/{repo}\n"
- f"Issue: #{number} — {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)'}"
+ for review in reviews
+ ]
+ comment_groups = await asyncio.gather(
+ *(
+ gitea.review_comments(owner, repo, number, review_id)
+ for review_id, _, _, _ in details
)
-
- async def pull_request_context(
- self, owner: str, repo: str, number: int
- ) -> tuple[PullRequestInfo, str]:
- pull = await self.gitea.pull_request(owner, repo, number)
- timeline = await self.gitea.issue_comments(owner, repo, number)
- reviews = await self.gitea.pull_reviews(owner, repo, number)
- commits = await self.gitea.pull_commits(owner, repo, number)
- review_text = await self._format_reviews(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} — {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)
+ )
+ sections: list[str] = []
+ for (review_id, author, state, body), comments in zip(
+ details, comment_groups, strict=True
+ ):
+ lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
+ for comment in comments:
+ 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:
return f"### {comment.author} at {comment.created_at}\n{comment.body}"
-
diff --git a/src/agentci/workflows/dispatch.py b/src/agentci/workflows/dispatch.py
new file mode 100644
index 0000000..fda4c26
--- /dev/null
+++ b/src/agentci/workflows/dispatch.py
@@ -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
diff --git a/src/agentci/workflows/dispatcher.py b/src/agentci/workflows/dispatcher.py
deleted file mode 100644
index 5990e91..0000000
--- a/src/agentci/workflows/dispatcher.py
+++ /dev/null
@@ -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)
diff --git a/src/agentci/workflows/implement.py b/src/agentci/workflows/implement.py
deleted file mode 100644
index e5782a3..0000000
--- a/src/agentci/workflows/implement.py
+++ /dev/null
@@ -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."
- )
diff --git a/src/agentci/workflows/implementation.py b/src/agentci/workflows/implementation.py
new file mode 100644
index 0000000..ae9c4e0
--- /dev/null
+++ b/src/agentci/workflows/implementation.py
@@ -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."
+ )
diff --git a/src/agentci/workflows/model.py b/src/agentci/workflows/model.py
new file mode 100644
index 0000000..8e2fd55
--- /dev/null
+++ b/src/agentci/workflows/model.py
@@ -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
+ )
diff --git a/src/agentci/workflows/plan.py b/src/agentci/workflows/plan.py
index d5a5179..84c3083 100644
--- a/src/agentci/workflows/plan.py
+++ b/src/agentci/workflows/plan.py
@@ -1,235 +1,189 @@
from __future__ import annotations
+from dataclasses import replace
from uuid import uuid4
-from agentci.domain.models import (
- DiscussionReply,
+from agentci.engine.model import (
Job,
- PlanArtifact,
- ReviewReport,
Workflow,
WorkflowKind,
WorkflowStatus,
)
-from agentci.reporting import reporter
-from agentci.workflows.common import (
- Dependencies,
+from agentci.engine.run import JobRun
+from agentci.workflows.context import build_issue_context
+from agentci.workflows.model import DiscussionReply, PlanArtifact
+from agentci.workflows.render import (
JobRejected,
agent_comment,
- finish_job,
+ final_comment,
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:
- def __init__(self, dependencies: Dependencies) -> None:
- self.deps = dependencies
+async def create_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
+ default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
+ workflow_id = str(uuid4())
+ workspace = services.settings.workspaces_dir / workflow_id / "repo"
- async def plan(self, job: Job) -> None:
- repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
- workflow_id = str(uuid4())
- 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,
- )
- workflow = Workflow(
- id=workflow_id,
- kind=WorkflowKind.PLAN,
- repo_owner=job.repo_owner,
- repo_name=job.repo_name,
- issue_number=job.issue_number,
- 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)
+ await run.stage("cloning")
+ base_sha = await services.git.clone(
+ job.repo_owner,
+ job.repo_name,
+ default_branch,
+ workspace,
+ )
+ workflow = Workflow(
+ id=workflow_id,
+ kind=WorkflowKind.PLAN,
+ repo_owner=job.repo_owner,
+ repo_name=job.repo_name,
+ issue_number=job.issue_number,
+ workspace_path=workspace,
+ base_sha=base_sha,
+ )
+ await run.create_workflow(workflow, "planning")
- async def discuss(self, job: Job) -> None:
- workflow = await self._latest_plan(job)
- if workflow.runtime != "opencode":
+ context = await build_issue_context(
+ services.gitea,
+ 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(
- "The latest plan predates OpenCode and cannot be resumed; "
- "start a new `/agent plan`."
+ f"Issue plan iteration is disabled because agent PR #{pull.number} "
+ "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."
- )
diff --git a/src/agentci/workflows/pull_request.py b/src/agentci/workflows/pull_request.py
index cf65fc8..125558e 100644
--- a/src/agentci/workflows/pull_request.py
+++ b/src/agentci/workflows/pull_request.py
@@ -1,142 +1,160 @@
from __future__ import annotations
-from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus
-from agentci.reporting import reporter
-from agentci.workflows.change_set import ChangeSet, result_comment
-from agentci.workflows.code_review import CodeReviewLoop
-from agentci.workflows.common import (
- Dependencies,
+from dataclasses import replace
+
+from agentci.engine.model import Job, WorkflowKind, WorkflowStatus
+from agentci.engine.run import JobRun
+from agentci.workflows.context import (
+ 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,
agent_comment,
- finish_job,
+ final_comment,
report_for_prompt,
- report_json,
- review_markdown,
+ result_comment,
)
+from agentci.workflows.review import review_implementation_once
+from agentci.workflows.services import WorkflowServices
-class PullRequestWorkflow:
- def __init__(self, dependencies: Dependencies) -> None:
- self.deps = dependencies
- self.review = CodeReviewLoop(dependencies)
- self.changes = ChangeSet(dependencies)
+async def iterate_implementation(
+ job: Job, run: JobRun, services: WorkflowServices
+) -> str:
+ pull_number = _pull_number(job)
+ 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_number = _pull_number(job)
- workflow = await self.deps.storage.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.")
- 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)
+ pull, context = await build_pull_request_context(
+ services.gitea, 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.")
- async def fix(self, job: Job) -> None:
- pull_number = _pull_number(job)
- pull, context = await self.deps.context.pull_request_context(
- job.repo_owner, job.repo_name, pull_number
- )
- if not pull.is_open:
- raise JobRejected("Fixes require an open pull request.")
- workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo"
- await reporter().progress("cloning pull request")
- await self.deps.git.clone(
- pull.head_owner,
- pull.head_repo,
- pull.head_branch,
- workspace,
- )
- await reporter().progress("installing development environment")
- await self.deps.development.prepare(workspace)
- prompt = self.deps.prompts.render(
- "fix",
- context=context,
- message=job.message or "(address the pull request feedback)",
- development_environment=self.deps.development.description,
- )
- await reporter().progress("fixing")
- session_id = await self.deps.opencode.create_session(workspace, "fix")
- 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,
- )
- sha = await self.changes.commit_and_push(
- job,
- workspace,
- pull.head_branch,
- result,
- set_upstream=False,
- commit_prefix="agent fix",
- )
- await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha)))
+ await run.link_workflow(workflow.id, "synchronizing branch")
+ await services.git.sync_branch(workflow.workspace_path, pull.head_branch)
+ await run.stage("installing development environment")
+ await services.development.prepare(workflow.workspace_path)
+ await run.stage("implementing iteration")
+ prompt = services.prompts.render(
+ "implementation_iterate",
+ context=context,
+ review=report_for_prompt(workflow.review_json),
+ message=job.message or "(perform one additional reviewed refinement)",
+ development_environment=services.development.description,
+ )
+ result = await services.opencode.resume(
+ session_id=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,
+ )
+
+ issue_context = await build_issue_context(
+ services.gitea,
+ services.repository,
+ job.repo_owner,
+ job.repo_name,
+ workflow.issue_number,
+ )
+ plan = await services.repository.latest_workflow(
+ job.repo_owner,
+ job.repo_name,
+ workflow.issue_number,
+ WorkflowKind.PLAN,
+ )
+ workflow, report = await review_implementation_once(
+ workflow,
+ issue_context=issue_context,
+ plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
+ pull_context=context,
+ services=services,
+ )
+ sha = await commit_and_push(
+ 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:
diff --git a/src/agentci/workflows/render.py b/src/agentci/workflows/render.py
new file mode 100644
index 0000000..78d8cfb
--- /dev/null
+++ b/src/agentci/workflows/render.py
@@ -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"\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"
diff --git a/src/agentci/workflows/review.py b/src/agentci/workflows/review.py
new file mode 100644
index 0000000..8765408
--- /dev/null
+++ b/src/agentci/workflows/review.py
@@ -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
diff --git a/src/agentci/workflows/services.py b/src/agentci/workflows/services.py
new file mode 100644
index 0000000..f73f2b8
--- /dev/null
+++ b/src/agentci/workflows/services.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from agentci.config.settings import Settings
+from agentci.engine.repository import Repository
+from agentci.integrations.development import DevelopmentEnvironment
+from agentci.integrations.git import Git
+from agentci.integrations.gitea.client import Gitea
+from agentci.integrations.opencode.client import OpenCode
+from agentci.prompts.library import PromptLibrary
+
+
+@dataclass(frozen=True)
+class WorkflowServices:
+ settings: Settings
+ repository: Repository
+ gitea: Gitea
+ git: Git
+ opencode: OpenCode
+ prompts: PromptLibrary
+ development: DevelopmentEnvironment
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..50ca4e0
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,130 @@
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+import agentci.api.app as app_module
+import agentci.api.lifespan as lifespan_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(lifespan_module, "build_runtime", build)
+ monkeypatch.setattr(lifespan_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(lifespan_module, "build_runtime", build)
+ monkeypatch.setattr(lifespan_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(lifespan_module, "build_runtime", fail_build)
+ monkeypatch.setattr(lifespan_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."}
diff --git a/tests/test_code_review.py b/tests/test_code_review.py
index 019e3f0..ac0fe62 100644
--- a/tests/test_code_review.py
+++ b/tests/test_code_review.py
@@ -1,22 +1,30 @@
from pathlib import Path
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,
- Job,
- JobKind,
+ PlanArtifact,
ReviewFinding,
ReviewReport,
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(
- summary="Needs work",
+ summary=summary,
findings=[
ReviewFinding(
severity=ReviewSeverity.MAJOR,
@@ -28,101 +36,352 @@ def serious_report() -> ReviewReport:
)
-class FakeOpenCode:
- def __init__(self, reports: list[ReviewReport]) -> None:
- 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=[])
+def clean_report() -> ReviewReport:
+ return ReviewReport(summary="Ready", findings=[])
-class FakeStorage:
- async def update_job(self, *_args, **_kwargs):
- return None
-
- async def update_workflow(self, *_args, **_kwargs):
- return None
-
-
-class FakePrompts:
- 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",
+def minor_report() -> ReviewReport:
+ return ReviewReport(
+ summary="Optional improvement",
+ findings=[
+ ReviewFinding(
+ severity=ReviewSeverity.MINOR,
+ title="Clarify wording",
+ detail="The wording could be clearer.",
+ recommendation="Tighten it when convenient.",
+ )
+ ],
)
- deps = SimpleNamespace(
- settings=settings,
- opencode=opencode,
- storage=FakeStorage(),
- prompts=FakePrompts(),
- development=SimpleNamespace(description="python"),
- )
- workflow = Workflow(
+
+
+class RecordingOpenCode:
+ def __init__(self, responses: list[BaseModel]) -> None:
+ self.responses = list(responses)
+ 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
+
+
+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",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=1,
- workspace_path=Path("."),
+ workspace_path=Path("/workspace/repo"),
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:
- clean = ReviewReport(summary="Ready", findings=[])
- loop, opencode, workflow, job = objects(4, [serious_report(), clean])
- _, report = await loop.run(
- job,
- workflow,
+def objects(
+ responses: list[BaseModel],
+ *,
+ plan_rounds: int = 4,
+ implementation_rounds: int = 3,
+) -> 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",
"canonical plan",
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 opencode.reviews == 2
- assert opencode.revisions == 1
-
-
-async def test_does_not_make_unreviewed_final_revision() -> None:
- 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
+ assert updated.artifact == initial.model_dump_json()
+ 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]
+ assert run.stages == ["reviewing implementation 1/5"]
diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py
index d8f063d..7d866b7 100644
--- a/tests/test_codegraph.py
+++ b/tests/test_codegraph.py
@@ -1,13 +1,17 @@
from pathlib import Path
-from agentci.adapters.codegraph import CodeGraphClient
+import pytest
+
+from agentci.integrations.codegraph import CodeGraph, CodeGraphError
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]:
- return b"", b""
+ return b"", self.stderr
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()
monkeypatch.setattr(
- "agentci.adapters.codegraph.asyncio.create_subprocess_exec",
+ "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
- await CodeGraphClient().prepare(workspace)
+ await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "init", str(workspace))]
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()
monkeypatch.setattr(
- "agentci.adapters.codegraph.asyncio.create_subprocess_exec",
+ "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
- await CodeGraphClient().prepare(workspace)
+ await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "sync", str(workspace))]
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.integrations.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.integrations.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.integrations.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
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 1dc320c..343dd8a 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,7 +1,7 @@
import pytest
-from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
-from agentci.domain.models import CommandName, JobKind
+from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
+from agentci.engine.model import CommandName, JobKind
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
) -> None:
command = parse_command(
- f"/agent {name.value}{'\n' * line_breaks}"
- "focus on the API\nand add tests"
+ f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests"
)
assert command is not None
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(
name: CommandName,
) -> None:
- command = parse_command(
- f"/agent {name.value}\r\n\r\n\r\n"
- "focus on the API\r\nand add tests"
- )
+ command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests")
assert command is not None
assert command.name is name
assert command.message == "focus on the API\r\nand add tests"
diff --git a/tests/test_config.py b/tests/test_config.py
index 91c7107..9ba8381 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,61 +1,150 @@
+import os
+from pathlib import Path
+
import pytest
from pydantic import ValidationError
-from agentci.config import Settings
+from agentci.config.settings import Settings
+
+
+@pytest.fixture(autouse=True)
+def isolate_agentci_environment(monkeypatch: pytest.MonkeyPatch) -> None:
+ for name in tuple(os.environ):
+ 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 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:
- settings = Settings(
- _env_file=None, # type: ignore[call-arg]
- install_scripts=" python, dotnet, company-tools, ",
- )
+ value = settings(install_scripts=" python, dotnet, company-tools, ")
- assert settings.install_scripts == ["python", "dotnet", "company-tools"]
+ assert value.install_scripts == ["python", "dotnet", "company-tools"]
@pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
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:
- settings = Settings(
- _env_file=None, # type: ignore[call-arg]
- install_scripts="",
+@pytest.mark.parametrize("value", ["", None, []])
+def test_empty_install_scripts_disable_setup(value: object) -> None:
+ assert settings(install_scripts=value).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 == []
-
-
-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"]
+ assert value.research_variant == "high"
@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:
with pytest.raises(ValidationError, match="provider/model"):
- Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg]
+ settings(**{field: "model-only"})
diff --git a/tests/test_context.py b/tests/test_context.py
index 9f0ed94..f308952 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,8 +1,12 @@
-from agentci.adapters.gitea_models import CommentInfo, IssueInfo
-from agentci.workflows.context import ContextBuilder
+from typing import cast
+
+from agentci.engine.repository import Repository
+from agentci.integrations.gitea.client import Gitea
+from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
+from agentci.workflows.context import build_issue_context, build_pull_request_context
-class FakeGitea:
+class FakeIssueGitea:
async def issue(self, *_args):
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
@@ -13,15 +17,199 @@ class FakeGitea:
]
-class FakeStorage:
+class FakeRepository:
async def operational_comment_ids(self, *_args):
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:
- builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type]
- context = await builder.issue_context("org", "repo", 2)
+ context = await build_issue_context(
+ cast(Gitea, FakeIssueGitea()),
+ cast(Repository, FakeRepository()),
+ "org",
+ "repo",
+ 2,
+ )
+
assert "Broken widget" in context
assert "Details" 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
diff --git a/tests/test_development.py b/tests/test_development.py
index d6b665b..0ca47ea 100644
--- a/tests/test_development.py
+++ b/tests/test_development.py
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest
-from agentci.adapters.development import (
+from agentci.integrations.development import (
DevelopmentEnvironment,
DevelopmentEnvironmentError,
)
@@ -37,7 +37,7 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
development = environment(tmp_path, ["first", "second"])
script(
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")
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)
+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.integrations.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:
workspace = tmp_path / "workspace"
workspace.mkdir()
diff --git a/tests/test_git.py b/tests/test_git.py
new file mode 100644
index 0000000..63354b5
--- /dev/null
+++ b/tests/test_git.py
@@ -0,0 +1,247 @@
+import asyncio
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from agentci.integrations.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")
diff --git a/tests/test_gitea.py b/tests/test_gitea.py
index bb55e38..15488a4 100644
--- a/tests/test_gitea.py
+++ b/tests/test_gitea.py
@@ -1,22 +1,345 @@
import json
+from collections.abc import AsyncGenerator, Callable, Coroutine
+from contextlib import asynccontextmanager
import httpx
-import respx
+import pytest
-from agentci.adapters.gitea import GiteaClient
+from agentci.integrations.gitea.client import Gitea, GiteaError
+from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
+
+Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
-@respx.mock
-async def test_updates_issue_comment_by_id() -> None:
- route = respx.patch(
- "https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
- ).mock(return_value=httpx.Response(200, json={"id": 17}))
- client = GiteaClient("https://gitea.example", "secret")
-
+@asynccontextmanager
+async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]:
+ client = Gitea(
+ "https://gitea.example/",
+ "secret",
+ retries=retries,
+ transport=httpx.MockTransport(handler),
+ )
try:
- await client.update_comment("org", "repo", 17, "updated status")
+ yield client
finally:
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.integrations.gitea.client.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.integrations.gitea.client.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.integrations.gitea.client.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.integrations.gitea.client.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.integrations.gitea.client.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]
diff --git a/tests/test_health.py b/tests/test_health.py
new file mode 100644
index 0000000..82e2c60
--- /dev/null
+++ b/tests/test_health.py
@@ -0,0 +1,77 @@
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from httpx import ASGITransport, AsyncClient, Response
+
+from agentci.api.routes.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
diff --git a/tests/test_implementation_setup.py b/tests/test_implementation_setup.py
deleted file mode 100644
index fd4e4c2..0000000
--- a/tests/test_implementation_setup.py
+++ /dev/null
@@ -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"]
diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py
index b1be54f..b453830 100644
--- a/tests/test_install_scripts.py
+++ b/tests/test_install_scripts.py
@@ -1,12 +1,154 @@
+import os
+import subprocess
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
- assert "/tmp/agentci-dotnet" not in script
- assert "DEV_TOOLS_DIR/runtime/dotnet" in script
- assert "NUGET_PACKAGES" in script
- assert 'export HOME="$DOTNET_CLI_HOME"' in script
+def executable(path: Path, content: str) -> None:
+ path.write_text(content)
+ path.chmod(0o755)
+
+
+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()
diff --git a/tests/test_job_run.py b/tests/test_job_run.py
new file mode 100644
index 0000000..648c0e8
--- /dev/null
+++ b/tests/test_job_run.py
@@ -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",
+ ]
diff --git a/tests/test_line_limits.py b/tests/test_line_limits.py
deleted file mode 100644
index 101196d..0000000
--- a/tests/test_line_limits.py
+++ /dev/null
@@ -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 == {}
-
diff --git a/tests/test_logging.py b/tests/test_logging.py
index 3170700..3101391 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -1,10 +1,106 @@
+import json
import logging
+import sys
+from collections.abc import Iterator
+from datetime import UTC, datetime
-from agentci.logging import configure_logging
+import pytest
+
+from agentci.observability.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()
+ 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("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"
diff --git a/tests/test_opencode.py b/tests/test_opencode.py
index 3df054c..7370ce8 100644
--- a/tests/test_opencode.py
+++ b/tests/test_opencode.py
@@ -5,8 +5,8 @@ from pathlib import Path
import httpx
import pytest
-from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
-from agentci.domain.models import AgentResult
+from agentci.integrations.opencode.client import OpenCode, OpenCodeError
+from agentci.workflows.model import AgentResult
API_DOCUMENT = {
"paths": {
@@ -42,9 +42,9 @@ class FakeCodeGraph:
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()
- return OpenCodeClient(
+ return OpenCode(
base_url="http://opencode:4096",
username="opencode",
password="server-secret",
@@ -74,20 +74,154 @@ async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Pa
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:
+ paths.append(request.url.path)
if request.url.path == "/global/health":
+ health_started.set()
+ await release_health.wait()
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
if request.url.path == "/doc":
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)
assert not await value.ready()
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.mkdir()
codegraph = FakeCodeGraph()
@@ -117,7 +251,9 @@ async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
)
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,
prompt="implement",
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)
prompts.append(body["parts"][0]["text"])
if len(prompts) == 1:
- return httpx.Response(
- 200, json={"info": {"error": {"name": "StructuredOutputError"}}}
- )
+ return httpx.Response(200, json={"info": {"error": {"name": "StructuredOutputError"}}})
return httpx.Response(
200,
json={
@@ -173,15 +307,160 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
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:
workspace = tmp_path / "workspace"
workspace.mkdir()
- aborted = False
+ abort_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
- nonlocal aborted
+ nonlocal abort_count
if request.url.path.endswith("/abort"):
- aborted = True
+ abort_count += 1
return httpx.Response(200, json=True)
raise httpx.ReadTimeout("slow", request=request)
@@ -197,7 +476,7 @@ async def test_aborts_timed_out_session(tmp_path: Path) -> None:
result_type=AgentResult,
)
- assert aborted
+ assert abort_count == 1
await value.close()
diff --git a/tests/test_opencode_abort.py b/tests/test_opencode_abort.py
index d584216..c35106c 100644
--- a/tests/test_opencode_abort.py
+++ b/tests/test_opencode_abort.py
@@ -3,11 +3,11 @@ from pathlib import Path
import httpx
import pytest
-from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
+from agentci.integrations.opencode.client import OpenCode, OpenCodeError
-def client(tmp_path: Path, status: int) -> OpenCodeClient:
- return OpenCodeClient(
+def client(tmp_path: Path, status: int) -> OpenCode:
+ return OpenCode(
base_url="http://opencode:4096",
username="opencode",
password="secret",
@@ -32,3 +32,50 @@ async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -
with pytest.raises(OpenCodeError):
await value.abort("session", tmp_path)
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()
diff --git a/tests/test_opencode_deployment.py b/tests/test_opencode_deployment.py
index 9d8208a..8aedb1b 100644
--- a/tests/test_opencode_deployment.py
+++ b/tests/test_opencode_deployment.py
@@ -1,10 +1,81 @@
import json
+import shutil
+import subprocess
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:
- 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 all(name not in config["agent"] for name in ("build", "plan", "general"))
@@ -22,22 +93,113 @@ def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
-def test_compose_removes_codex_sandbox_exceptions() -> None:
- root = Path(__file__).parents[1]
- compose = (root / "compose.yaml").read_text()
- dockerfile = (root / "Dockerfile").read_text()
+def test_compose_services_have_expected_runtime_contract() -> None:
+ agentci = _service("agentci")
+ opencode = _service("opencode")
+ 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",
+ ]
+
+ image = "${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}"
+ assert _scalar(agentci, "image") == image
+ assert _scalar(opencode, "image") == image
+ assert all(line.strip() != "build:" for line in [*agentci, *opencode])
+
+ 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 "no_cache" not in compose
- assert "opencode_home:/var/lib/opencode" in compose
- assert "HOME: /etc/opencode/home" in compose
- assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
- assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose
- assert 'OPENCODE_ENABLE_EXA: "1"' in compose
- assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose
- assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose
- assert compose.count("/run/agentci:mode=1777") == 2
- 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_gitea_workflow_publishes_master_images() -> None:
+ lines = (ROOT / ".gitea" / "workflows" / "publish-image.yaml").read_text().splitlines()
+ push = _section(_section(lines, "on", indent=0), "push", indent=2)
+
+ assert _sequence(push, "branches", indent=4) == ["master"]
+ assert _mapping(lines, "env", indent=0)["IMAGE_NAME"] == (
+ "git.krtss.de/stanponomarev/agentci"
+ )
+ assert " ${{ env.IMAGE_NAME }}:latest" in lines
+ assert " ${{ env.IMAGE_NAME }}:${{ gitea.sha }}" in lines
+ assert " username: ${{ secrets.REGISTRY_USERNAME }}" in lines
+ assert " password: ${{ secrets.REGISTRY_TOKEN }}" in lines
+
+
+def test_container_pins_opencode_major_version_contract() -> None:
+ lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()]
+ build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")}
+
+ assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments
+ assert any(
+ line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines
+ )
+
+
+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
diff --git a/tests/test_opencode_support.py b/tests/test_opencode_support.py
index 759cebe..e5f05ae 100644
--- a/tests/test_opencode_support.py
+++ b/tests/test_opencode_support.py
@@ -1,4 +1,6 @@
-from agentci.adapters.opencode_support import api_contract_ready, models_ready
+import pytest
+
+from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
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)
-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 = {
"connected": ["openai"],
"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(payload, {("openai", "model", "missing")})
- payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False
- assert not models_ready(payload, {("openai", "model", "high")})
+ assert not models_ready(
+ payload,
+ {
+ ("openai", "model", "high"),
+ ("openai", "other", None),
+ },
+ )
diff --git a/tests/test_prompts.py b/tests/test_prompts.py
new file mode 100644
index 0000000..56884e3
--- /dev/null
+++ b/tests/test_prompts.py
@@ -0,0 +1,109 @@
+from pathlib import Path
+from string import Template
+
+import pytest
+from pydantic import BaseModel
+
+import agentci.prompts
+from agentci.integrations.opencode.schemas import load_schema
+from agentci.prompts.library 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)
diff --git a/tests/test_reducer.py b/tests/test_reducer.py
new file mode 100644
index 0000000..f101151
--- /dev/null
+++ b/tests/test_reducer.py
@@ -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(),
+ "\nAgent job `job` received (`command`; stage: `received`).",
+ ),
+ (
+ queued(),
+ "\nAgent job `job` queued (`plan`; stage: `queued`).",
+ ),
+ (
+ replace(
+ running(),
+ status=JobStatus.SUCCEEDED,
+ stage="completed",
+ comment_body="# Done",
+ ),
+ "\n# Done",
+ ),
+ (
+ replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"),
+ "\nAgent job `job` was rejected: not allowed",
+ ),
+ (
+ replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"),
+ "\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]
diff --git a/tests/test_repository.py b/tests/test_repository.py
new file mode 100644
index 0000000..2594248
--- /dev/null
+++ b/tests/test_repository.py
@@ -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]
diff --git a/tests/test_repository_concurrency.py b/tests/test_repository_concurrency.py
new file mode 100644
index 0000000..cb3bed1
--- /dev/null
+++ b/tests/test_repository_concurrency.py
@@ -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))
diff --git a/tests/test_repository_migration.py b/tests/test_repository_migration.py
new file mode 100644
index 0000000..25abb26
--- /dev/null
+++ b/tests/test_repository_migration.py
@@ -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,
+ )
diff --git a/tests/test_repository_tasks.py b/tests/test_repository_tasks.py
new file mode 100644
index 0000000..1ff1917
--- /dev/null
+++ b/tests/test_repository_tasks.py
@@ -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],
+ )
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
new file mode 100644
index 0000000..d6dcfc3
--- /dev/null
+++ b/tests/test_runtime.py
@@ -0,0 +1,174 @@
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+import agentci.application.runtime as runtime_module
+from agentci.application.runtime import Runtime
+from agentci.config.settings import Settings
+
+
+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(schemas_dir=tmp_path / "schemas")
+ 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)
+
+ repository_constructor.assert_called_once_with(settings.database_path)
+ 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=prompts.schemas_dir,
+ 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
diff --git a/tests/test_runtime_scripts.py b/tests/test_runtime_scripts.py
new file mode 100644
index 0000000..70e62ef
--- /dev/null
+++ b/tests/test_runtime_scripts.py
@@ -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
diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py
deleted file mode 100644
index 2670a77..0000000
--- a/tests/test_state_machine.py
+++ /dev/null
@@ -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"))
diff --git a/tests/test_storage.py b/tests/test_storage.py
deleted file mode 100644
index 15b9a8f..0000000
--- a/tests/test_storage.py
+++ /dev/null
@@ -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
diff --git a/tests/test_webhook.py b/tests/test_webhook.py
index 4673375..aede8f3 100644
--- a/tests/test_webhook.py
+++ b/tests/test_webhook.py
@@ -1,28 +1,35 @@
import hashlib
import hmac
+import json
from types import SimpleNamespace
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.api.routes.webhook import router
+from agentci.engine.model import IncomingCommand
+from agentci.integrations.gitea.webhooks import (
+ incoming_command_from_payload,
+ valid_signature,
+)
-class FakeHost:
- def __init__(self, duplicate: bool = False) -> None:
- self.events = []
+class FakeRepository:
+ def __init__(self, *, duplicate: bool = False) -> None:
+ self.accepted: list[IncomingCommand] = []
self.duplicate = duplicate
- async def receive(self, event):
- self.events.append(event)
- state = SimpleNamespace(id="job", receive_sequence=1)
- return SimpleNamespace(state=state, duplicate=self.duplicate)
+ async def accept(self, event: IncomingCommand) -> SimpleNamespace:
+ self.accepted.append(event)
+ job = SimpleNamespace(id="job", receive_sequence=1)
+ 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 = {
"action": "created",
- "comment": {"id": 8, "body": body, "user": {"login": "alice"}},
+ "comment": {"id": 8, "body": body, "user": {"login": requester}},
"repository": {"name": "repo", "owner": {"login": "org"}},
"issue": {"number": 4},
"is_pull": is_pull,
@@ -32,40 +39,149 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
return value
-async def test_command_is_forwarded_without_parsing() -> None:
- host = FakeHost()
- event = _event_from_payload(
- "delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
+def encoded(value: object) -> bytes:
+ return json.dumps(value).encode()
+
+
+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
- response = await _handle_command(SimpleNamespace(state_machine=host), event)
+ headers = {
+ "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 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:
- event = _event_from_payload("delivery", payload("/agent plan"))
- assert event is not None
- response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
- assert response.status_code == 200
+@pytest.mark.parametrize("event", ["issue_comment", "push"])
+async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str) -> None:
+ repository = FakeRepository()
+
+ 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:
- event = _event_from_payload("", payload("/agent plan"))
- assert event is not None
- with pytest.raises(HTTPException) as raised:
- await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
- assert raised.value.status_code == 400
+async def test_signed_unsupported_event_is_ignored_without_parsing() -> None:
+ repository = FakeRepository()
+ 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 repository.accepted == []
-def test_signature_validation() -> None:
- signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
+@pytest.mark.parametrize(
+ ("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 = incoming_command_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 not valid_signature(b"secret", b"{}", "")
assert not valid_signature(b"secret", b"{}", "bad")
diff --git a/tests/test_worker.py b/tests/test_worker.py
index 5bf2446..7b59346 100644
--- a/tests/test_worker.py
+++ b/tests/test_worker.py
@@ -1,44 +1,587 @@
import asyncio
+from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
-from typing import cast
-from agentci.domain.models import Workflow, WorkflowKind
-from agentci.domain.state_machine import JobState
-from agentci.worker import Worker, _safe_error
+import pytest
+
+from agentci.application.worker.errors import safe_error
+from agentci.application.worker.runner import Worker
+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.integrations.gitea.models import CommentInfo
+from agentci.workflows.render import JobRejected
+
+MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
-class FakeStorage:
- def __init__(self, workflow=None) -> None:
+class FakeRepository:
+ def __init__(
+ self,
+ value: Job | None = None,
+ *,
+ workflow: Workflow | None = None,
+ ) -> None:
+ self.job = value
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
+ 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:
- def __init__(self) -> None:
- self.aborted = set()
+ def __init__(self, ready: list[bool] | None = None) -> None:
+ self.ready_results = ready or [True]
+ self.ready_calls = 0
+ self.aborted: list[tuple[str, Path]] = []
- async def abort(self, session_id, workspace):
- self.aborted.add((session_id, workspace))
+ async def ready(self) -> bool:
+ 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(
- storage=storage, # type: ignore[arg-type]
- state_machine=SimpleNamespace(), # type: ignore[arg-type]
- gitea=SimpleNamespace(), # type: ignore[arg-type]
- opencode=opencode, # type: ignore[arg-type]
- dispatcher=SimpleNamespace(), # type: ignore[arg-type]
+ repository=repository, # type: ignore[arg-type]
+ gitea=gitea or FakeGitea(), # type: ignore[arg-type]
+ opencode=opencode or FakeOpenCode(), # type: ignore[arg-type]
+ services=SimpleNamespace(), # type: ignore[arg-type]
poll_seconds=1,
- max_concurrent_jobs=2,
+ max_concurrent_jobs=max_concurrent_jobs,
workspaces_dir=tmp_path,
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.application.worker.execution.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.application.worker.execution.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.application.worker.execution.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.application.worker.execution.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""
+ 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"
workflow = Workflow(
id="flow",
@@ -48,46 +591,59 @@ async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
issue_number=1,
workspace_path=workspace,
base_sha="base",
- primary_session_id="primary",
- reviewer_session_id="reviewer",
+ primary_session_id=primary,
+ reviewer_session_id=reviewer,
)
opencode = FakeOpenCode()
- state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
- await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
- cast(JobState, state)
- )
- assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
+
+ await make_worker(
+ tmp_path, FakeRepository(workflow=workflow), opencode=opencode
+ )._abort_job_sessions(job(workflow_id="flow"))
+
+ 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()
- state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
- await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
- cast(JobState, state)
+
+ await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(
+ 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:
- value = worker(tmp_path, FakeStorage(), FakeOpenCode())
- queues = []
+async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
+ tmp_path: Path,
+) -> 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:
- pass
+ await make_worker(
+ tmp_path, FakeRepository(workflow=workflow), opencode=opencode
+ )._abort_job_sessions(job(workflow_id="flow", session_id="one-shot"))
- async def loop(queue, _stop) -> None:
- queues.append(queue)
+ assert opencode.aborted == []
- 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
- assert queues.count("jobs") == 2
+ await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job())
+
+ assert opencode.aborted == []
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 len(value) == 1000
diff --git a/tests/test_workflow_dispatch.py b/tests/test_workflow_dispatch.py
new file mode 100644
index 0000000..392c0f8
--- /dev/null
+++ b/tests/test_workflow_dispatch.py
@@ -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,)
diff --git a/tests/test_workflow_implementation.py b/tests/test_workflow_implementation.py
new file mode 100644
index 0000000..2c82cde
--- /dev/null
+++ b/tests/test_workflow_implementation.py
@@ -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.integrations.gitea.models 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"\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 == []
diff --git a/tests/test_workflow_plan.py b/tests/test_workflow_plan.py
new file mode 100644
index 0000000..e0cbc79
--- /dev/null
+++ b/tests/test_workflow_plan.py
@@ -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.integrations.gitea.models 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"\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 == ("\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 == "\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 == []
diff --git a/tests/test_workflow_pull_request.py b/tests/test_workflow_pull_request.py
new file mode 100644
index 0000000..8f34e51
--- /dev/null
+++ b/tests/test_workflow_pull_request.py
@@ -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.integrations.gitea.models 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 == (
+ "\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 == (
+ "\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)])
diff --git a/tests/test_workflow_render.py b/tests/test_workflow_render.py
new file mode 100644
index 0000000..0b05ed6
--- /dev/null
+++ b/tests/test_workflow_render.py
@@ -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") == (
+ "\nPlan body"
+ )
+ assert final_comment("plan", "flow-1", "Plan body", report()) == (
+ f"\nPlan body\n\n{review_markdown(report())}"
+ )
+ assert (
+ final_comment("plan", "flow-1", "Plan body", ReviewReport(summary="Ready", findings=[]))
+ == "\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)
diff --git a/uv.lock b/uv.lock
index 68cedb4..68371f5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -19,7 +19,7 @@ dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
- { name = "respx" },
+ { name = "pytest-cov" },
{ name = "ruff" },
]
@@ -37,7 +37,7 @@ dev = [
{ name = "pyright", specifier = ">=1.1.403" },
{ name = "pytest", specifier = ">=8.4,<9" },
{ 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" },
]
@@ -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" },
]
+[[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]]
name = "fastapi"
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" },
]
+[[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]]
name = "python-dotenv"
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" },
]
-[[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]]
name = "ruff"
version = "0.15.22"