5 Commits
Author SHA1 Message Date
StanPonomarev d6a0010632 fix action tags 2026-07-25 12:36:15 +02:00
StanPonomarev 4de8c2624a ci and compose update 2026-07-25 12:33:53 +02:00
StanPonomarev 0526406472 reorg files 2026-07-22 23:49:44 +02:00
StanPonomarev 98ac4abca1 rewrite phase 1 2026-07-22 23:10:23 +02:00
StanPonomarev 7527831af6 make concurrent 2026-07-22 17:37:14 +02:00
126 changed files with 9826 additions and 3052 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+2 -2
View File
@@ -1,10 +1,10 @@
GITEA_NETWORK=gitea GITEA_NETWORK=gitea
AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:latest
AGENTCI_GITEA_URL=http://gitea:3000 AGENTCI_GITEA_URL=http://gitea:3000
AGENTCI_BOT_USERNAME=agentci AGENTCI_BOT_USERNAME=agentci
AGENTCI_BOT_NAME=Agent CI AGENTCI_BOT_NAME=Agent CI
AGENTCI_BOT_EMAIL=agentci@localhost AGENTCI_BOT_EMAIL=agentci@localhost
OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_USERNAME=opencode
AGENTCI_OPENCODE_VERSION=^1
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
AGENTCI_PLAN_VARIANT= AGENTCI_PLAN_VARIANT=
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
@@ -18,9 +18,9 @@ AGENTCI_CONTEXT7_API_KEY=
AGENTCI_PLAN_REVIEW_ROUNDS=4 AGENTCI_PLAN_REVIEW_ROUNDS=4
AGENTCI_IMPLEMENT_REVIEW_ROUNDS=3 AGENTCI_IMPLEMENT_REVIEW_ROUNDS=3
AGENTCI_TURN_TIMEOUT_SECONDS=3600 AGENTCI_TURN_TIMEOUT_SECONDS=3600
AGENTCI_MAX_CONCURRENT_JOBS=2
# Comma-delimited built-in or custom script names, for example: python,dotnet,company-tools # Comma-delimited built-in or custom script names, for example: python,dotnet,company-tools
AGENTCI_INSTALL_SCRIPTS= AGENTCI_INSTALL_SCRIPTS=
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900 AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
AGENTCI_PYTHON_VERSION=3.13 AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0 AGENTCI_DOTNET_CHANNEL=10.0
CODEGRAPH_VERSION=1.3.1
+41
View File
@@ -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 }}
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="~/repos/agentci/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/agentci.iml" filepath="$PROJECT_DIR$/.idea/agentci.iml" />
</modules>
</component>
</project>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PyToolsState">
<option name="tools">
<map>
<entry key="pyright">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
<entry key="ruff">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
</map>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+132
View File
@@ -0,0 +1,132 @@
# Agent CI repository instructions
## Scope
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
recovery, deployment, or the security boundary.
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
the requested change merely for consistency.
## Repository map
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
task claiming, and the `JobRun` event interface.
- `src/agentci/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_<area>.py
uv run pytest tests/test_<area>.py::test_<behavior>
uv run pytest -k '<expression>'
```
Run the standard Python checks before completion:
```sh
uv run ruff check .
uv run pyright
uv run pytest
```
Coverage is diagnostic and has no required threshold:
```sh
uv run pytest --cov=agentci --cov-branch
```
For deployment-related changes, also run:
```sh
docker compose config
```
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
OpenCode configuration. It may require network access and takes longer than the normal checks.
## Engineering conventions
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
rather than mutating state in place.
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
events and task requests.
- Apply state transitions, event persistence, and resulting task creation atomically through
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
deterministic comment reconciliation.
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
failed and its sessions are aborted rather than replayed.
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
log credentials, secret contents, authorization headers, or private prompt data.
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
applied.
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
hand.
## Testing conventions
- Add regression tests for behavior changes, especially reducer transitions, persistence and
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
asynchronous.
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
error text rather than private implementation details.
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
or network access.
## Security and operational boundaries
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime
databases, cloned private repositories, or Docker volume contents. Do not send secrets or private
repository content to external search or research services.
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
secret-file loading.
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
or external skills, expose its server to the host, add privileged/capability settings, or add
writable host mounts without an explicit security review.
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
credentials to them.
- Git pushes performed by Agent CI must remain non-forcing.
- Do not run provider authentication, start/restart deployment services, modify production data, or
perform other live operations unless the user explicitly requests it.
- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`,
`.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`.
## Completion expectations
- Run focused tests first, then all applicable standard checks. If a check cannot run, report the
exact command and reason.
- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands,
configuration, deployment, recovery behavior, or security assumptions.
- Summarize behavior changes, validation performed, and any migration, compatibility, or security
implications in the final response or pull-request description.
+54 -13
View File
@@ -4,6 +4,23 @@ Agent CI is a private Gitea webhook host that turns issue and pull-request comme
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
private OpenCode server on the same Docker network as Gitea. private OpenCode server on the same Docker network as Gitea.
## Architecture
The service has one durable execution path:
```text
webhook -> repository -> reducer -> durable task -> worker -> workflow -> integration
```
`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions
atomically to SQLite and persists the resulting tasks. `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 ## Commands
| Location | Command | Behavior | | Location | Command | Behavior |
@@ -16,9 +33,11 @@ private OpenCode server on the same Docker network as Gitea.
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. | | PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Commands The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Commands
are durably sequenced when their webhook arrives, then authorized and executed in that receive are durably sequenced when their webhook arrives, then authorized and executed in receive order per
order. Each command gets one Gitea comment, which is reconciled asynchronously through queued, issue or pull request. Up to `AGENTCI_MAX_CONCURRENT_JOBS` unrelated targets execute concurrently;
running, and terminal states. Deleted comments are rediscovered by their hidden marker or recreated. the default is two. Each command gets one Gitea comment, which is reconciled asynchronously through
queued, running, and terminal states. Deleted comments are rediscovered by their hidden marker or
recreated.
## Deploy ## Deploy
@@ -27,10 +46,11 @@ running, and terminal states. Deleted comments are rediscovered by their hidden
OpenCode models. OpenCode models.
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and 3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files. `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 ```sh
docker compose build docker login git.krtss.de
docker compose pull
``` ```
5. Authenticate the configured OpenCode providers before starting the persistent server: 5. Authenticate the configured OpenCode providers before starting the persistent server:
@@ -56,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 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. 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:<full-commit-sha>`. 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:<full-commit-sha>` in `.env` to deploy an immutable
revision.
## OpenCode ## OpenCode
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
@@ -65,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 `AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to
`openai/gpt-5.6-luna` with `low`. `openai/gpt-5.6-luna` with `low`.
`AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults The `AGENTCI_OPENCODE_VERSION` Docker build argument controls the npm version or range installed into
to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker the image and defaults to `^1`. The build verifies that the resolved version is still OpenCode 1.x
may reuse the cached installation layer until the configured version or build inputs change. and prints it. Docker may reuse the cached installation layer until the configured version or build
Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built. 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 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. permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
@@ -131,7 +168,9 @@ sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs a
SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
than reducer state. Control effects retry with bounded backoff. A delayed authorization blocks later than reducer state. Control effects retry with bounded backoff. A delayed authorization blocks later
workflow execution but not later control work. workflow execution for the same issue or pull request, but not other targets or later control work.
Development environment installers remain serialized because they share the persistent tools
directory.
The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for
rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a
@@ -149,9 +188,11 @@ uv sync
uv run ruff check . uv run ruff check .
uv run pyright uv run pyright
uv run pytest uv run pytest
uv run pytest --cov=agentci --cov-branch
docker compose config docker compose config
docker compose build
``` ```
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside The coverage command is an opt-in diagnostic report; the regular test run remains the default and
Python so orchestration modules remain small and readable. coverage percentage is not used as a pass threshold.
Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow.
+6 -15
View File
@@ -1,12 +1,6 @@
services: services:
agentci: agentci:
image: agentci:local image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
build:
context: .
args:
AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
TEA_VERSION: ${TEA_VERSION:-0.14.2}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
opencode: opencode:
@@ -29,6 +23,7 @@ services:
AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4} AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4}
AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3} AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600} AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600}
AGENTCI_MAX_CONCURRENT_JOBS: ${AGENTCI_MAX_CONCURRENT_JOBS:-2}
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS: ${AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS:-900} AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS: ${AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS:-900}
AGENTCI_INSTALL_SCRIPTS: ${AGENTCI_INSTALL_SCRIPTS:-} AGENTCI_INSTALL_SCRIPTS: ${AGENTCI_INSTALL_SCRIPTS:-}
AGENTCI_PYTHON_VERSION: ${AGENTCI_PYTHON_VERSION:-3.13} AGENTCI_PYTHON_VERSION: ${AGENTCI_PYTHON_VERSION:-3.13}
@@ -38,7 +33,7 @@ services:
- webhook_secret - webhook_secret
- opencode_server_password - opencode_server_password
volumes: volumes:
- agentci_data:/var/lib/agentci - ./data/agentci:/var/lib/agentci
- ./install-scripts:/etc/agentci/install-scripts:ro - ./install-scripts:/etc/agentci/install-scripts:ro
tmpfs: tmpfs:
- /run/agentci:mode=1777 - /run/agentci:mode=1777
@@ -49,7 +44,7 @@ services:
- agentci_control - agentci_control
opencode: opencode:
image: agentci:local image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"] command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -80,8 +75,8 @@ services:
- gitea_token - gitea_token
- opencode_server_password - opencode_server_password
volumes: volumes:
- agentci_data:/var/lib/agentci - ./data/agentci:/var/lib/agentci
- opencode_home:/var/lib/opencode - ./data/opencode:/var/lib/opencode
tmpfs: tmpfs:
- /run/agentci:mode=1777 - /run/agentci:mode=1777
expose: expose:
@@ -104,10 +99,6 @@ secrets:
opencode_server_password: opencode_server_password:
file: ./secrets/opencode_server_password file: ./secrets/opencode_server_password
volumes:
agentci_data:
opencode_home:
networks: networks:
gitea: gitea:
external: true external: true
+1 -1
View File
@@ -20,7 +20,7 @@ dev = [
"pyright>=1.1.403", "pyright>=1.1.403",
"pytest>=8.4,<9", "pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2", "pytest-asyncio>=1.1,<2",
"respx>=0.22,<1", "pytest-cov>=6,<8",
"ruff>=0.12,<1", "ruff>=0.12,<1",
] ]
-2
View File
@@ -1,3 +1 @@
"""Gitea-triggered OpenCode workflow host.""" """Gitea-triggered OpenCode workflow host."""
__version__ = "0.1.0"
+2 -3
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
import uvicorn import uvicorn
from agentci.app import create_app from agentci.api.app import create_app
from agentci.config import Settings from agentci.config.settings import Settings
def main() -> None: def main() -> None:
@@ -19,4 +19,3 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-2
View File
@@ -1,2 +0,0 @@
"""External-system adapters."""
-75
View File
@@ -1,75 +0,0 @@
from __future__ import annotations
import logging
import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TypeVar
T = TypeVar("T")
log = logging.getLogger(__name__)
def now() -> str:
return datetime.now(UTC).isoformat()
class Database:
def __init__(self, database_path: Path, migrations_dir: Path) -> None:
self.database_path = database_path
self.migrations_dir = migrations_dir
async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True)
try:
await self._run(self._initialize_sync)
except Exception:
log.exception(
"database initialization failed", extra={"operation": "database.initialize"}
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _initialize_sync(self, connection: sqlite3.Connection) -> None:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row[0] for row in connection.execute("SELECT version FROM schema_migrations")
}
for path in sorted(self.migrations_dir.glob("*.sql")):
version = int(path.name.split("_", 1)[0])
if version in applied:
continue
connection.executescript(path.read_text())
connection.execute(
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
(version, now()),
)
async def _update(self, table: str, row_id: str, updates: dict[str, object]) -> None:
if not updates:
return
columns = ", ".join(f"{column}=?" for column in updates)
values = [*updates.values(), row_id]
await self._run(
lambda connection: connection.execute(
f"UPDATE {table} SET {columns} WHERE id=?", values
)
)
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
# Operations are deliberately tiny and serialized by the single worker.
# Avoid a thread pool so SQLite transactions retain deterministic ordering.
with self._connect() as connection:
return operation(connection)
-211
View File
@@ -1,211 +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.status IN ('received', 'queued', 'running'))"""
row = connection.execute(
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""",
(queue, now()),
).fetchone()
if row is None:
connection.commit()
return None
changed = connection.execute(
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
"WHERE id=? AND status='pending'",
(now(), row["id"]),
)
if changed.rowcount != 1:
connection.rollback()
return None
connection.commit()
return ListenerTask(
row["id"], row["job_id"], row["source_event_id"], row["listener"],
row["queue"], row["attempts"] + 1,
)
-77
View File
@@ -1,77 +0,0 @@
import json
from pathlib import Path
from time import monotonic
from typing import Any
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
try:
value = json.loads((schemas_dir / name).read_text())
except (OSError, ValueError) as exc:
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"Result schema {name} is not a JSON object")
return value
def api_contract_ready(document: object) -> bool:
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
return False
paths = document["paths"]
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
if any(method not in paths.get(path, {}) for path, method in fixed.items()):
return False
session_paths = [path for path in paths if path.startswith("/session/{")]
has_message = any(
path.endswith("/message") and "post" in paths[path] for path in session_paths
)
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
return has_message and has_abort
def models_ready(
payload: object, requirements: set[tuple[str, str, str | None]]
) -> bool:
if not isinstance(payload, dict):
return False
connected = set(payload.get("connected", []))
providers = {
item.get("id"): item
for item in payload.get("all", [])
if isinstance(item, dict) and isinstance(item.get("models"), dict)
}
for provider_id, model_id, variant in requirements:
provider = providers.get(provider_id)
if provider_id not in connected or not isinstance(provider, dict):
return False
model: Any = provider["models"].get(model_id)
if not isinstance(model, dict) or model.get("status") == "deprecated":
return False
if model.get("capabilities", {}).get("toolcall") is not True:
return False
if variant and variant not in model.get("variants", {}):
return False
return True
-115
View File
@@ -1,115 +0,0 @@
from __future__ import annotations
import sqlite3
from agentci.domain.events import JobEvent, WorkflowCreated
from agentci.domain.models import JobKind, JobStatus
from agentci.domain.state_machine import JobState, Transition
def insert_event(connection: sqlite3.Connection, event_id: str, event: JobEvent, ts: str) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), ts),
)
def insert_tasks(
connection: sqlite3.Connection, event_id: str, transition: Transition, timestamp: str
) -> None:
for ordinal, notification in enumerate(transition.notifications):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.state.id,
event_id,
ordinal,
notification.listener,
notification.queue,
timestamp,
timestamp,
),
)
def _values(state: JobState) -> tuple[object, ...]:
return (
state.id, state.kind, state.target_key, state.repo_owner, state.repo_name,
state.issue_number, state.pr_number, state.requester, state.message, state.comment_id,
state.delivery_id, state.receive_sequence, state.command_body, state.workflow_id,
state.status, state.stage, state.error, state.runtime_session_id,
state.accepted_comment_id, state.comment_body,
)
def insert_state(connection: sqlite3.Connection, state: JobState, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_values(state), timestamp),
)
def replace_state(
connection: sqlite3.Connection, state: JobState, previous: JobState, timestamp: str
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and state.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and state.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_values(state)[1:], started, finished, state.id),
)
def optional_state(connection: sqlite3.Connection, job_id: str) -> JobState | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return state_from_row(row) if row else None
def state(connection: sqlite3.Connection, job_id: str) -> JobState:
value = optional_state(connection, job_id)
if value is None:
raise KeyError(f"Unknown job {job_id}")
return value
def state_from_row(row: sqlite3.Row) -> JobState:
return JobState(
id=row["id"], kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"], repo_owner=row["repo_owner"], repo_name=row["repo_name"],
issue_number=row["issue_number"], pr_number=row["pr_number"], requester=row["requester"],
message=row["message"], comment_id=row["comment_id"], delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"], command_body=row["command_body"],
workflow_id=row["workflow_id"], status=JobStatus(row["status"]), stage=row["stage"],
error=row["error"], runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"], comment_body=row["comment_body"],
)
def insert_workflow(connection: sqlite3.Connection, event: WorkflowCreated, ts: str) -> None:
workflow = event.workflow
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id, workflow.kind, workflow.repo_owner, workflow.repo_name,
workflow.issue_number, workflow.pr_number, workflow.base_sha, workflow.branch,
str(workflow.workspace_path), workflow.primary_session_id,
workflow.reviewer_session_id, workflow.artifact, workflow.review_json,
workflow.status, workflow.runtime, ts, ts,
),
)
-7
View File
@@ -1,7 +0,0 @@
from agentci.adapters.job_store import JobStore
from agentci.adapters.workflow_store import WorkflowStore
class Storage(JobStore, WorkflowStore):
"""Combined durable job and workflow repository."""
-156
View File
@@ -1,156 +0,0 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from agentci.adapters.database import Database, now
from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus
class WorkflowStore(Database):
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"SELECT * FROM workflows WHERE id=?", (workflow_id,)
).fetchone()
)
)
async def create_workflow(self, workflow: Workflow) -> None:
timestamp = now()
await self._run(
lambda connection: connection.execute(
"""
INSERT INTO workflows (
id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id,
reviewer_session_id, artifact, review_json, status, runtime,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
workflow.id,
workflow.kind,
workflow.repo_owner,
workflow.repo_name,
workflow.issue_number,
workflow.pr_number,
workflow.base_sha,
workflow.branch,
str(workflow.workspace_path),
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status,
workflow.runtime,
timestamp,
timestamp,
),
)
)
async def update_workflow(self, workflow: Workflow) -> None:
await self._update(
"workflows",
workflow.id,
{
"pr_number": workflow.pr_number,
"branch": workflow.branch,
"primary_session_id": workflow.primary_session_id,
"reviewer_session_id": workflow.reviewer_session_id,
"artifact": workflow.artifact,
"review_json": workflow.review_json,
"status": workflow.status,
"updated_at": now(),
},
)
async def latest_workflow(
self,
owner: str,
repo: str,
issue: int,
kind: WorkflowKind,
) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"""
SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=?
AND kind=? AND status=?
ORDER BY created_at DESC LIMIT 1
""",
(owner, repo, issue, kind, WorkflowStatus.COMPLETED),
).fetchone()
)
)
async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"""
SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=?
ORDER BY created_at DESC LIMIT 1
""",
(owner, repo, pr, WorkflowKind.IMPLEMENT),
).fetchone()
)
)
async def implementation_workflows(
self, owner: str, repo: str, issue: int
) -> list[Workflow]:
return await self._run(
lambda connection: [
item
for row in connection.execute(
"""
SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=?
AND pr_number IS NOT NULL
ORDER BY created_at DESC
""",
(owner, repo, issue, WorkflowKind.IMPLEMENT),
)
if (item := workflow_from_row(row)) is not None
]
)
async def fail_job_workflow(self, job_id: str) -> None:
await self._run(
lambda connection: connection.execute(
"""
UPDATE workflows SET status=?, updated_at=?
WHERE id=(SELECT workflow_id FROM jobs WHERE id=?)
AND status=?
""",
(WorkflowStatus.FAILED, now(), job_id, WorkflowStatus.ACTIVE),
)
)
def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
if row is None:
return None
return Workflow(
id=row["id"],
kind=WorkflowKind(row["kind"]),
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
reviewer_session_id=row["reviewer_session_id"],
artifact=row["artifact"],
review_json=row["review_json"],
status=WorkflowStatus(row["status"]),
)
+1 -2
View File
@@ -1,2 +1 @@
"""HTTP API.""" """HTTP application and routes."""
+25
View File
@@ -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()
+14
View File
@@ -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)]
+29
View File
@@ -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)
+45
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""FastAPI route modules."""
@@ -1,6 +1,8 @@
from __future__ import annotations 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() router = APIRouter()
@@ -11,8 +13,8 @@ async def live() -> dict[str, str]:
@router.get("/health/ready") @router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]: async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]:
if not await request.app.state.container.opencode.ready(): if not await runtime.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "opencode provider is not connected"} return {"status": "not-ready", "reason": "opencode provider is not connected"}
return {"status": "ready"} return {"status": "ready"}
@@ -1,30 +1,28 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import hmac
import json import json
import logging import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from agentci.domain.models import CommandEvent from agentci.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() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SUPPORTED_EVENTS = {
"issue_comment",
"pull_request_comment",
"pull_request_review_comment",
}
@router.post("/webhooks/gitea") @router.post("/webhooks/gitea")
async def webhook(request: Request) -> Response: async def webhook(request: Request, runtime: RuntimeDependency) -> Response:
container = request.app.state.container
body = await request.body() body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "") signature = request.headers.get("X-Gitea-Signature", "")
if not valid_signature(container.settings.webhook_secret, body, signature): if not valid_signature(runtime.settings.webhook_secret, body, signature):
log.warning( log.warning(
"webhook signature rejected", "webhook signature rejected",
extra={"operation": "webhook.verify", "path": request.url.path}, extra={"operation": "webhook.verify", "path": request.url.path},
@@ -41,29 +39,26 @@ async def webhook(request: Request) -> Response:
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
try: try:
payload = json.loads(body) payload = json.loads(body)
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", ""), request.headers.get("X-Gitea-Delivery", ""),
payload, payload,
) )
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: except (KeyError, TypeError, ValueError) as exc:
log.warning( log.warning(
"webhook payload rejected", "webhook payload rejected",
extra={"operation": "webhook.parse", "stage": event_name}, extra={"operation": "webhook.parse", "stage": event_name},
exc_info=exc, exc_info=exc,
) )
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
if event is None or event.requester.casefold() == container.settings.bot_username.casefold(): if event is None or event.requester.casefold() == runtime.settings.bot_username.casefold():
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name}) log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
return await _handle_command(container, event) return await _handle_command(runtime, event)
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool: async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
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:
extra = {"operation": "command.handle", "target": event.target_key} extra = {"operation": "command.handle", "target": event.target_key}
if not event.body.strip().startswith("/agent"): if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -71,45 +66,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
log.info("agent command received", extra=extra) log.info("agent command received", extra=extra)
try: try:
result = await container.state_machine.receive(event) result = await runtime.repository.accept(event)
except Exception: except Exception:
log.exception("could not persist command", extra=extra) log.exception("could not persist command", extra=extra)
raise raise
if result.duplicate: if result.duplicate:
log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id}) log.info("duplicate command ignored", extra={**extra, "job_id": result.job.id})
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
log.info( log.info(
"agent command persisted", "agent command persisted",
extra={ extra={
**extra, **extra,
"job_id": result.state.id, "job_id": result.job.id,
"receive_sequence": result.state.receive_sequence, "receive_sequence": result.job.receive_sequence,
}, },
) )
return Response(status_code=status.HTTP_202_ACCEPTED) return Response(status_code=status.HTTP_202_ACCEPTED)
def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEvent | None:
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 "",
)
-71
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Application composition and durable orchestration."""
+95
View File
@@ -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
@@ -0,0 +1 @@
"""Durable task runner and task handlers."""
@@ -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)
@@ -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"<!-- agentci:job id={latest.id} -->"
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
)
+6
View File
@@ -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]
@@ -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.",
),
)
@@ -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),
)
+134
View File
@@ -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)
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Service configuration."""
@@ -45,6 +45,7 @@ class Settings(BaseSettings):
turn_timeout_seconds: int = Field(default=3600, ge=60) turn_timeout_seconds: int = Field(default=3600, ge=60)
install_script_timeout_seconds: int = Field(default=900, ge=1) install_script_timeout_seconds: int = Field(default=900, ge=1)
worker_poll_seconds: float = Field(default=1.0, ge=0.1) worker_poll_seconds: float = Field(default=1.0, ge=0.1)
max_concurrent_jobs: int = Field(default=2, ge=1, le=32)
install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list) install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list)
install_scripts_dir: Path = Path("/etc/agentci/install-scripts") install_scripts_dir: Path = Path("/etc/agentci/install-scripts")
python_version: str = "3.13" python_version: str = "3.13"
-103
View File
@@ -1,103 +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,
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
-2
View File
@@ -1,2 +0,0 @@
"""Domain types and policies."""
-189
View File
@@ -1,189 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import JobKind, JobStatus
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Notification:
listener: str
queue: str = "control"
@dataclass(frozen=True)
class JobState:
id: str
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None
runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass(frozen=True)
class Transition:
state: JobState
notifications: tuple[Notification, ...] = ()
RECONCILE = Notification("reconcile_comment")
def next_state(state: JobState | None, event: JobEvent) -> Transition:
if state is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
created = JobState(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
return Transition(created, (Notification("authorize"),))
if event.job_id != state.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(state, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if state.status is not JobStatus.RUNNING:
return Transition(state)
failed = replace(
state,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
listeners = [Notification("abort_sessions"), RECONCILE]
if state.workflow_id:
listeners.insert(1, Notification("fail_workflow"))
return Transition(failed, tuple(listeners))
if state.status is JobStatus.RECEIVED:
return _received(state, event)
if state.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(state, status=JobStatus.RUNNING, stage="starting"), (RECONCILE,)
)
if state.status is JobStatus.RUNNING:
return _running(state, event)
raise InvalidTransition(f"{event.type} is invalid while job is {state.status}")
def _received(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(state.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=state.is_pull_request)
except CommandError as exc:
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
state,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
return Transition(queued, (Notification("execute", "jobs"), RECONCILE))
def _running(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(state, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(state, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(state, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(state, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
state,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
listeners = [RECONCILE]
if state.workflow_id:
listeners.append(Notification("fail_workflow"))
return Transition(replace(state, status=status, stage=stage, error=error), tuple(listeners))
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(state: JobState) -> str:
marker = f"<!-- agentci:job id={state.id} -->"
if state.status is JobStatus.SUCCEEDED and state.comment_body:
body = state.comment_body
elif state.status is JobStatus.REJECTED:
body = f"Agent job `{state.id}` was rejected: {state.error}"
elif state.status is JobStatus.FAILED:
body = f"Agent job `{state.id}` failed during `{state.stage}`: {state.error}"
else:
kind = state.kind.value if state.kind else "command"
body = f"Agent job `{state.id}` {state.status.value} (`{kind}`; stage: `{state.stage}`)."
return f"{marker}\n{body}"
+1
View File
@@ -0,0 +1 @@
"""Durable job and workflow engine."""
+271
View File
@@ -0,0 +1,271 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from agentci.engine.events import JobEvent
from agentci.engine.model import (
Job,
JobKind,
JobStatus,
QueueName,
Task,
TaskKind,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import Transition
def now() -> str:
return datetime.now(UTC).isoformat()
def connect(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path, timeout=30)
try:
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
except Exception:
connection.close()
raise
return connection
def initialize(connection: sqlite3.Connection, migrations_dir: Path) -> None:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {row[0] for row in connection.execute("SELECT version FROM schema_migrations")}
for path in sorted(migrations_dir.glob("*.sql")):
version = int(path.name.split("_", 1)[0])
if version in applied:
continue
connection.executescript(path.read_text())
connection.execute(
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
(version, now()),
)
def insert_event(
connection: sqlite3.Connection,
event_id: str,
event: JobEvent,
timestamp: str,
) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), timestamp),
)
def insert_tasks(
connection: sqlite3.Connection,
event_id: str,
transition: Transition,
timestamp: str,
) -> None:
for ordinal, task in enumerate(transition.tasks):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.job.id,
event_id,
ordinal,
task.kind.value,
task.queue.value,
timestamp,
timestamp,
),
)
def insert_job(connection: sqlite3.Connection, job: Job, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_job_values(job), timestamp),
)
def replace_job(
connection: sqlite3.Connection,
job: Job,
previous: Job,
timestamp: str,
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and job.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and job.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_job_values(job)[1:], started, finished, job.id),
)
def optional_job(connection: sqlite3.Connection, job_id: str) -> Job | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return job_from_row(row) if row else None
def required_job(connection: sqlite3.Connection, job_id: str) -> Job:
job = optional_job(connection, job_id)
if job is None:
raise KeyError(f"Unknown job {job_id}")
return job
def job_from_row(row: sqlite3.Row) -> Job:
return Job(
id=row["id"],
kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"],
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
requester=row["requester"],
message=row["message"],
comment_id=row["comment_id"],
delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"],
command_body=row["command_body"],
workflow_id=row["workflow_id"],
status=JobStatus(row["status"]),
stage=row["stage"],
error=row["error"],
runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"],
comment_body=row["comment_body"],
)
def insert_workflow(
connection: sqlite3.Connection,
workflow: Workflow,
timestamp: str,
) -> None:
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id,
workflow.kind.value,
workflow.repo_owner,
workflow.repo_name,
workflow.issue_number,
workflow.pr_number,
workflow.base_sha,
workflow.branch,
str(workflow.workspace_path),
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
workflow.runtime,
timestamp,
timestamp,
),
)
def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
if row is None:
return None
return Workflow(
id=row["id"],
kind=WorkflowKind(row["kind"]),
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
reviewer_session_id=row["reviewer_session_id"],
artifact=row["artifact"],
review_json=row["review_json"],
status=WorkflowStatus(row["status"]),
)
def claim_task(connection: sqlite3.Connection, queue: str) -> Task | None:
connection.execute("BEGIN IMMEDIATE")
fifo = ""
if queue == QueueName.JOBS.value:
fifo = """AND NOT EXISTS (
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
AND earlier.target_key = j.target_key
AND earlier.status IN ('received', 'queued', 'running'))"""
row = connection.execute(
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
ORDER BY {"j.receive_sequence" if queue == QueueName.JOBS.value else "t.id"} LIMIT 1""",
(queue, now()),
).fetchone()
if row is None:
connection.commit()
return None
changed = connection.execute(
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
"WHERE id=? AND status='pending'",
(now(), row["id"]),
)
if changed.rowcount != 1:
connection.rollback()
return None
connection.commit()
return Task(
id=row["id"],
job_id=row["job_id"],
source_event_id=row["source_event_id"],
kind=TaskKind(row["listener"]),
queue=QueueName(row["queue"]),
attempts=row["attempts"] + 1,
)
def _job_values(job: Job) -> tuple[object, ...]:
return (
job.id,
job.kind.value if job.kind else None,
job.target_key,
job.repo_owner,
job.repo_name,
job.issue_number,
job.pr_number,
job.requester,
job.message,
job.comment_id,
job.delivery_id,
job.receive_sequence,
job.command_body,
job.workflow_id,
job.status.value,
job.stage,
job.error,
job.runtime_session_id,
job.accepted_comment_id,
job.comment_body,
)
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from agentci.domain.models import CommandName, JobKind, ParsedCommand from agentci.engine.model import CommandName, JobKind, ParsedCommand
COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$") COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$")
@@ -4,7 +4,7 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from agentci.domain.models import Workflow from agentci.engine.model import Workflow
class Event(BaseModel): class Event(BaseModel):
@@ -4,8 +4,6 @@ from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field
class CommandName(StrEnum): class CommandName(StrEnum):
PLAN = "plan" PLAN = "plan"
@@ -44,43 +42,17 @@ class WorkflowStatus(StrEnum):
FAILED = "failed" FAILED = "failed"
class ReviewSeverity(StrEnum): class TaskKind(StrEnum):
BLOCKING = "blocking" AUTHORIZE = "authorize"
MAJOR = "major" EXECUTE = "execute"
MINOR = "minor" RECONCILE_COMMENT = "reconcile_comment"
FAIL_WORKFLOW = "fail_workflow"
ABORT_SESSIONS = "abort_sessions"
class PlanArtifact(BaseModel): class QueueName(StrEnum):
plan_markdown: str = Field(min_length=1) CONTROL = "control"
JOBS = "jobs"
class DiscussionReply(BaseModel):
markdown: str = Field(min_length=1)
class AgentResult(BaseModel):
summary_markdown: str = Field(min_length=1)
tests: list[str] = Field(default_factory=list)
class ReviewFinding(BaseModel):
severity: ReviewSeverity
title: str
detail: str
location: str | None = None
recommendation: str
class ReviewReport(BaseModel):
summary: str
findings: list[ReviewFinding] = Field(default_factory=list)
@property
def has_serious_findings(self) -> bool:
return any(
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
for finding in self.findings
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -90,7 +62,7 @@ class ParsedCommand:
@dataclass(frozen=True) @dataclass(frozen=True)
class CommandEvent: class IncomingCommand:
delivery_id: str delivery_id: str
comment_id: int comment_id: int
repo_owner: str repo_owner: str
@@ -110,26 +82,35 @@ class CommandEvent:
return f"{self.repo_owner}/{self.repo_name}:{target}" return f"{self.repo_owner}/{self.repo_name}:{target}"
@dataclass @dataclass(frozen=True)
class Job: class Job:
id: str id: str
kind: JobKind
target_key: str target_key: str
repo_owner: str repo_owner: str
repo_name: str repo_name: str
issue_number: int issue_number: int
pr_number: int | None pr_number: int | None
requester: str requester: str
message: str
comment_id: int comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None workflow_id: str | None = None
status: JobStatus = JobStatus.QUEUED
stage: str = "queued"
accepted_comment_id: int | None = None
runtime_session_id: str | None = None runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass @dataclass(frozen=True)
class Workflow: class Workflow:
id: str id: str
kind: WorkflowKind kind: WorkflowKind
@@ -146,3 +127,19 @@ class Workflow:
artifact: str | None = None artifact: str | None = None
review_json: str | None = None review_json: str | None = None
status: WorkflowStatus = WorkflowStatus.ACTIVE status: WorkflowStatus = WorkflowStatus.ACTIVE
@dataclass(frozen=True)
class TaskRequest:
kind: TaskKind
queue: QueueName
@dataclass(frozen=True)
class Task:
id: int
job_id: str
source_event_id: str
kind: TaskKind
queue: QueueName
attempts: int
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
from agentci.engine.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Job, JobStatus, QueueName, TaskKind, TaskRequest
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Transition:
job: Job
tasks: tuple[TaskRequest, ...] = ()
RECONCILE = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
def reduce_job(current: Job | None, event: JobEvent) -> Transition:
if current is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
job = Job(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
task = TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL)
return Transition(job, (task,))
if event.job_id != current.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(current, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if current.status is not JobStatus.RUNNING:
return Transition(current)
failed = replace(
current,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
tasks = [TaskRequest(TaskKind.ABORT_SESSIONS, QueueName.CONTROL), RECONCILE]
if current.workflow_id:
tasks.insert(1, TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
return Transition(failed, tuple(tasks))
if current.status is JobStatus.RECEIVED:
return _received(current, event)
if current.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(current, status=JobStatus.RUNNING, stage="starting"),
(RECONCILE,),
)
if current.status is JobStatus.RUNNING:
return _running(current, event)
raise InvalidTransition(f"{event.type} is invalid while job is {current.status}")
def _received(current: Job, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(current, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(current.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=current.is_pull_request)
except CommandError as exc:
return Transition(
replace(current, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
current,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
tasks = (
TaskRequest(TaskKind.EXECUTE, QueueName.JOBS),
RECONCILE,
)
return Transition(queued, tasks)
def _running(current: Job, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(current, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(current, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(current, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(current, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
current,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
tasks = [RECONCILE]
if current.workflow_id:
tasks.append(TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
return Transition(
replace(current, status=status, stage=stage, error=error),
tuple(tasks),
)
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(job: Job) -> str:
marker = f"<!-- agentci:job id={job.id} -->"
if job.status is JobStatus.SUCCEEDED and job.comment_body:
body = job.comment_body
elif job.status is JobStatus.REJECTED:
body = f"Agent job `{job.id}` was rejected: {job.error}"
elif job.status is JobStatus.FAILED:
body = f"Agent job `{job.id}` failed during `{job.stage}`: {job.error}"
else:
kind = job.kind.value if job.kind else "command"
body = f"Agent job `{job.id}` {job.status.value} (`{kind}`; stage: `{job.stage}`)."
return f"{marker}\n{body}"
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
import logging
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TypeVar
from uuid import UUID, uuid5
from agentci.engine import _sqlite
from agentci.engine.events import CommandReceived, JobEvent, WorkflowCreated
from agentci.engine.model import (
IncomingCommand,
Job,
QueueName,
Task,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import reduce_job
T = TypeVar("T")
log = logging.getLogger(__name__)
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ApplyResult:
job: Job
duplicate: bool
class Repository:
def __init__(self, database_path: Path, migrations_dir: Path | None = None) -> None:
self.database_path = database_path
self.migrations_dir = migrations_dir or Path(__file__).parent.parent / "migrations"
async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True)
try:
await self._run(lambda connection: _sqlite.initialize(connection, self.migrations_dir))
except Exception:
log.exception(
"database initialization failed",
extra={"operation": "database.initialize"},
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
async def accept(self, command: IncomingCommand) -> ApplyResult:
job_id = str(uuid5(JOB_NAMESPACE, command.delivery_id))
event_id = f"delivery:{command.delivery_id}"
def operation(connection: sqlite3.Connection) -> ApplyResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
job = _sqlite.required_job(connection, duplicate["job_id"])
connection.commit()
return ApplyResult(job, True)
sequence = connection.execute(
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
).fetchone()[0]
event = CommandReceived(
job_id=job_id,
delivery_id=command.delivery_id,
receive_sequence=sequence,
command_body=command.body,
target_key=command.target_key,
repo_owner=command.repo_owner,
repo_name=command.repo_name,
issue_number=command.issue_number,
pr_number=command.pr_number,
requester=command.requester,
comment_id=command.comment_id,
)
transition = reduce_job(None, event)
timestamp = _sqlite.now()
_sqlite.insert_event(connection, event_id, event, timestamp)
_sqlite.insert_job(connection, transition.job, timestamp)
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return ApplyResult(transition.job, False)
return await self._run(operation)
async def apply(self, event_id: str, event: JobEvent) -> ApplyResult:
def operation(connection: sqlite3.Connection) -> ApplyResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
existing_job_id = duplicate["job_id"]
if existing_job_id != event.job_id:
raise RuntimeError(
f"Event ID {event_id!r} belongs to job {existing_job_id!r}, "
f"not {event.job_id!r}"
)
job = _sqlite.required_job(connection, existing_job_id)
connection.commit()
return ApplyResult(job, True)
current = _sqlite.required_job(connection, event.job_id)
transition = reduce_job(current, event)
timestamp = _sqlite.now()
_sqlite.insert_event(connection, event_id, event, timestamp)
if isinstance(event, WorkflowCreated):
_sqlite.insert_workflow(connection, event.workflow, timestamp)
_sqlite.replace_job(connection, transition.job, current, timestamp)
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return ApplyResult(transition.job, False)
return await self._run(operation)
async def get_job(self, job_id: str) -> Job | None:
return await self._run(lambda connection: _sqlite.optional_job(connection, job_id))
async def claim_task(self, queue: QueueName | str) -> Task | None:
queue_name = queue.value if isinstance(queue, QueueName) else queue
return await self._run(lambda connection: _sqlite.claim_task(connection, queue_name))
async def complete_task(self, task_id: int) -> None:
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
(_sqlite.now(), task_id),
)
)
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
delay = min(2 ** min(attempts, 8), 300)
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
(available, error[:1000], task_id),
)
)
async def recover_tasks(self) -> None:
def operation(connection: sqlite3.Connection) -> None:
with connection:
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND queue='control'"""
)
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status='queued')"""
)
connection.execute(
"""UPDATE listener_tasks SET status='failed', finished_at=?,
error='Service restarted after execution began'
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status<>'queued')""",
(_sqlite.now(),),
)
await self._run(operation)
async def running_jobs(self) -> list[Job]:
return await self._run(
lambda connection: [
_sqlite.job_from_row(row)
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
]
)
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
return await self._run(
lambda connection: {
value
for row in connection.execute(
"SELECT accepted_comment_id, started_comment_id FROM jobs "
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
(owner, repo, issue),
)
for value in row
if value is not None
}
)
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute("SELECT * FROM workflows WHERE id=?", (workflow_id,)).fetchone()
)
)
async def save_workflow(self, workflow: Workflow) -> None:
def operation(connection: sqlite3.Connection) -> None:
updated = connection.execute(
"""UPDATE workflows SET pr_number=?, branch=?, primary_session_id=?,
reviewer_session_id=?, artifact=?, review_json=?, status=?, updated_at=?
WHERE id=?""",
(
workflow.pr_number,
workflow.branch,
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
_sqlite.now(),
workflow.id,
),
)
if updated.rowcount != 1:
raise KeyError(f"Unknown workflow {workflow.id}")
await self._run(operation)
async def latest_workflow(
self,
owner: str,
repo: str,
issue: int,
kind: WorkflowKind,
) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=?
AND kind=? AND status=?
ORDER BY created_at DESC LIMIT 1""",
(owner, repo, issue, kind.value, WorkflowStatus.COMPLETED.value),
).fetchone()
)
)
async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=?
ORDER BY created_at DESC LIMIT 1""",
(owner, repo, pr, WorkflowKind.IMPLEMENT.value),
).fetchone()
)
)
async def implementation_workflows(self, owner: str, repo: str, issue: int) -> list[Workflow]:
return await self._run(
lambda connection: [
workflow
for row in connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=?
AND pr_number IS NOT NULL
ORDER BY created_at DESC""",
(owner, repo, issue, WorkflowKind.IMPLEMENT.value),
)
if (workflow := _sqlite.workflow_from_row(row)) is not None
]
)
async def fail_job_workflow(self, job_id: str) -> None:
await self._run(
lambda connection: connection.execute(
"""UPDATE workflows SET status=?, updated_at=?
WHERE id=(SELECT workflow_id FROM jobs WHERE id=?)
AND status=?""",
(
WorkflowStatus.FAILED.value,
_sqlite.now(),
job_id,
WorkflowStatus.ACTIVE.value,
),
)
)
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
connection = _sqlite.connect(self.database_path)
try:
with connection:
return operation(connection)
finally:
connection.close()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from agentci.engine.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Workflow
from agentci.engine.repository import Repository
type ReportEvent = JobProgress | RuntimeSessionLinked | WorkflowCreated | WorkflowLinked
class JobRun:
def __init__(self, repository: Repository, job_id: str, task_id: int) -> None:
self.repository = repository
self.job_id = job_id
self.task_id = task_id
self._sequence = 0
async def stage(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
async def _emit(self, event: ReportEvent) -> None:
self._sequence += 1
event_id = f"task:{self.task_id}:report:{self._sequence}"
await self.repository.apply(event_id, event)
+1
View File
@@ -0,0 +1 @@
"""External service and process integrations."""
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
pass pass
class CodeGraphClient: class CodeGraph:
async def prepare(self, workspace: Path) -> None: async def prepare(self, workspace: Path) -> None:
self._exclude_index(workspace) self._exclude_index(workspace)
index = workspace / ".codegraph" / "codegraph.db" index = workspace / ".codegraph" / "codegraph.db"
@@ -32,6 +32,7 @@ class DevelopmentEnvironment:
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self.python_version = python_version self.python_version = python_version
self.dotnet_channel = dotnet_channel self.dotnet_channel = dotnet_channel
self._prepare_lock = asyncio.Lock()
@property @property
def description(self) -> str: def description(self) -> str:
@@ -40,10 +41,11 @@ class DevelopmentEnvironment:
async def prepare(self, workspace: Path) -> None: async def prepare(self, workspace: Path) -> None:
if not self.scripts: if not self.scripts:
return return
self.tools_dir.mkdir(parents=True, exist_ok=True) async with self._prepare_lock:
(self.tools_dir / "bin").mkdir(exist_ok=True) self.tools_dir.mkdir(parents=True, exist_ok=True)
for name in self.scripts: (self.tools_dir / "bin").mkdir(exist_ok=True)
await self._run(name, self._resolve(name), workspace) for name in self.scripts:
await self._run(name, self._resolve(name), workspace)
def _resolve(self, name: str) -> Path: def _resolve(self, name: str) -> Path:
path = self.scripts_dir / name path = self.scripts_dir / name
@@ -13,7 +13,7 @@ class GitError(RuntimeError):
pass pass
class GitClient: class Git:
def __init__( def __init__(
self, self,
*, *,
@@ -31,9 +31,6 @@ class GitClient:
self.commit_name = commit_name self.commit_name = commit_name
self.commit_email = commit_email self.commit_email = commit_email
def clone_url(self, owner: str, repo: str) -> str:
return f"{self.gitea_url}/{owner}/{repo}.git"
async def clone( async def clone(
self, self,
owner: str, owner: str,
@@ -47,7 +44,7 @@ class GitClient:
"--branch", "--branch",
branch, branch,
"--single-branch", "--single-branch",
self.clone_url(owner, repo), f"{self.gitea_url}/{owner}/{repo}.git",
str(destination), str(destination),
cwd=destination.parent, cwd=destination.parent,
authenticated=True, authenticated=True,
@@ -85,6 +82,7 @@ class GitClient:
"-m", "-m",
message, message,
cwd=workspace, cwd=workspace,
command_name="commit",
) )
return await self.current_sha(workspace) return await self.current_sha(workspace)
@@ -101,8 +99,10 @@ class GitClient:
*args: str, *args: str,
cwd: Path, cwd: Path,
authenticated: bool = False, authenticated: bool = False,
command_name: str | None = None,
) -> str: ) -> str:
operation = f"git.{args[0]}" command_name = command_name or args[0]
operation = f"git.{command_name}"
started = monotonic() started = monotonic()
log.info("git step started", extra={"operation": operation}) log.info("git step started", extra={"operation": operation})
environment = os.environ.copy() environment = os.environ.copy()
@@ -130,14 +130,14 @@ class GitClient:
"git step could not start", "git step could not start",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
) )
raise GitError(f"Could not run git {args[0]}: {exc}") from exc raise GitError(f"Could not run git {command_name}: {exc}") from exc
if process.returncode: if process.returncode:
detail = stderr.decode(errors="replace").strip() detail = stderr.decode(errors="replace").strip()
log.error( log.error(
"git step failed", "git step failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
) )
raise GitError(f"git {args[0]} failed: {detail[-1000:]}") raise GitError(f"git {command_name} failed: {detail[-1000:]}")
log.info( log.info(
"git step completed", "git step completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
@@ -0,0 +1 @@
"""Gitea API and webhook integration."""
@@ -7,12 +7,7 @@ from typing import Any
import httpx import httpx
from agentci.adapters.gitea_models import ( from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
CommentInfo,
IssueInfo,
PullRequestInfo,
RepositoryInfo,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -21,14 +16,22 @@ class GiteaError(RuntimeError):
pass pass
class GiteaClient: class Gitea:
def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None: def __init__(
self,
base_url: str,
token: str,
*,
retries: int = 3,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.retries = retries self.retries = retries
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1", base_url=f"{self.base_url}/api/v1",
headers={"Authorization": f"token {token}", "Accept": "application/json"}, headers={"Authorization": f"token {token}", "Accept": "application/json"},
timeout=30, timeout=30,
transport=transport,
) )
async def close(self) -> None: async def close(self) -> None:
@@ -41,14 +44,9 @@ class GiteaClient:
permission = str(response.json().get("permission", "")).lower() permission = str(response.json().get("permission", "")).lower()
return permission in {"write", "admin", "owner"} return permission in {"write", "admin", "owner"}
async def repository(self, owner: str, repo: str) -> RepositoryInfo: async def default_branch(self, owner: str, repo: str) -> str:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json() data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return RepositoryInfo( return str(data["default_branch"])
owner=owner,
name=repo,
full_name=data.get("full_name", f"{owner}/{repo}"),
default_branch=data["default_branch"],
)
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo: async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json() data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
@@ -3,14 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@dataclass(frozen=True)
class RepositoryInfo:
owner: str
name: str
full_name: str
default_branch: str
@dataclass(frozen=True) @dataclass(frozen=True)
class IssueInfo: class IssueInfo:
number: int number: int
@@ -43,4 +35,3 @@ class PullRequestInfo:
@property @property
def is_open(self) -> bool: def is_open(self) -> bool:
return self.state == "open" and not self.merged return self.state == "open" and not self.merged
@@ -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 "",
)
@@ -0,0 +1 @@
"""OpenCode API integration."""
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from contextlib import suppress
from pathlib import Path from pathlib import Path
from time import monotonic from time import monotonic
from typing import Any, TypeVar from typing import Any, TypeVar
@@ -9,16 +10,9 @@ from typing import Any, TypeVar
import httpx import httpx
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from agentci.adapters.codegraph import CodeGraphClient from agentci.integrations.codegraph import CodeGraph
from agentci.adapters.opencode_support import ( from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
api_contract_ready, from agentci.integrations.opencode.schemas import load_schema
directory_headers,
elapsed_ms,
error_message,
load_schema,
model_parts,
models_ready,
)
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -28,7 +22,7 @@ class OpenCodeError(RuntimeError):
pass pass
class OpenCodeClient: class OpenCode:
def __init__( def __init__(
self, self,
*, *,
@@ -39,7 +33,7 @@ class OpenCodeClient:
health_directory: Path, health_directory: Path,
required_models: tuple[tuple[str, str | None], ...], required_models: tuple[tuple[str, str | None], ...],
timeout_seconds: int, timeout_seconds: int,
codegraph: CodeGraphClient | None = None, codegraph: CodeGraph | None = None,
transport: httpx.AsyncBaseTransport | None = None, transport: httpx.AsyncBaseTransport | None = None,
) -> None: ) -> None:
self.schemas_dir = schemas_dir self.schemas_dir = schemas_dir
@@ -48,8 +42,9 @@ class OpenCodeClient:
(*model_parts(model), variant) for model, variant in required_models (*model_parts(model), variant) for model, variant in required_models
} }
self._contract_valid: bool | None = None self._contract_valid: bool | None = None
self._readiness_task: asyncio.Task[bool] | None = None
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self.codegraph = codegraph or CodeGraphClient() self.codegraph = codegraph or CodeGraph()
self._active_sessions: dict[str, Path] = {} self._active_sessions: dict[str, Path] = {}
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=base_url.rstrip("/"), base_url=base_url.rstrip("/"),
@@ -59,11 +54,27 @@ class OpenCodeClient:
) )
async def close(self) -> None: async def close(self) -> None:
if self._readiness_task is not None and not self._readiness_task.done():
self._readiness_task.cancel()
with suppress(asyncio.CancelledError):
await self._readiness_task
self._readiness_task = None
for session_id, workspace in tuple(self._active_sessions.items()): for session_id, workspace in tuple(self._active_sessions.items()):
await self.abort(session_id, workspace, best_effort=True) await self.abort(session_id, workspace, best_effort=True)
await self.client.aclose() await self.client.aclose()
async def ready(self) -> bool: async def ready(self) -> bool:
task = self._readiness_task
if task is None:
task = asyncio.create_task(self._check_ready())
self._readiness_task = task
try:
return await asyncio.shield(task)
finally:
if task.done() and self._readiness_task is task:
self._readiness_task = None
async def _check_ready(self) -> bool:
try: try:
health = await self.client.get("/global/health", timeout=10) health = await self.client.get("/global/health", timeout=10)
health.raise_for_status() health.raise_for_status()
@@ -85,28 +96,6 @@ class OpenCodeClient:
except (httpx.HTTPError, TypeError, ValueError): except (httpx.HTTPError, TypeError, ValueError):
return False return False
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
session_id = await self.create_session(workspace, schema_name)
result = await self.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
return session_id, result
async def create_session(self, workspace: Path, title: str) -> str: async def create_session(self, workspace: Path, title: str) -> str:
response = await self._request( response = await self._request(
"POST", "POST",
@@ -143,7 +132,10 @@ class OpenCodeClient:
result_type=result_type, result_type=result_type,
) )
except (asyncio.CancelledError, OpenCodeError): except (asyncio.CancelledError, OpenCodeError):
await asyncio.shield(self.abort(session_id, workspace)) try:
await asyncio.shield(self.abort(session_id, workspace))
except OpenCodeError as exc:
log.warning("OpenCode failed session could not be aborted", exc_info=exc)
raise raise
finally: finally:
self._active_sessions.pop(session_id, None) self._active_sessions.pop(session_id, None)
@@ -186,7 +178,6 @@ class OpenCodeClient:
json=payload, json=payload,
) )
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
await self.abort(session_id, workspace)
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds" message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
raise OpenCodeError(message) from exc raise OpenCodeError(message) from exc
info = response.get("info") info = response.get("info")
@@ -213,7 +204,10 @@ class OpenCodeClient:
raise OpenCodeError("OpenCode did not return a valid result") raise OpenCodeError("OpenCode did not return a valid result")
async def _request( async def _request(
self, method: str, path: str, *, self,
method: str,
path: str,
*,
workspace: Path, workspace: Path,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -248,3 +242,27 @@ class OpenCodeClient:
log.warning("OpenCode session could not be aborted", exc_info=exc) log.warning("OpenCode session could not be aborted", exc_info=exc)
return return
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
@@ -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
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Service observability configuration."""
@@ -15,6 +15,7 @@ LOG_FIELDS = (
"method", "method",
"path", "path",
"status_code", "status_code",
"error_message",
"attempt", "attempt",
"item_count", "item_count",
"duration_ms", "duration_ms",
@@ -37,9 +38,14 @@ class JsonFormatter(logging.Formatter):
try: try:
return json.dumps(payload, ensure_ascii=False) return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError): except (TypeError, ValueError):
payload["message"] = "Log record could not be serialized" fallback = {
payload["exception"] = traceback.format_exc() "timestamp": payload["timestamp"],
return json.dumps(payload, ensure_ascii=False) "level": payload["level"],
"logger": payload["logger"],
"message": "Log record could not be serialized",
"exception": traceback.format_exc(),
}
return json.dumps(fallback, ensure_ascii=False)
def configure_logging() -> None: def configure_logging() -> None:
+1 -14
View File
@@ -1,14 +1 @@
from __future__ import annotations """Prompt templates and structured-output contracts."""
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)
+17
View File
@@ -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"
-80
View File
@@ -1,80 +0,0 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from agentci.domain.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import Workflow
from agentci.state_machine import StateMachine
class NullReporter:
final_body: str | None = None
async def progress(self, _stage: str) -> None:
pass
async def create_workflow(self, _workflow: Workflow, _stage: str) -> None:
pass
async def link_workflow(self, _workflow_id: str, _stage: str) -> None:
pass
async def link_runtime_session(self, _session_id: str) -> None:
pass
def finish(self, body: str) -> None:
self.final_body = body
_current: ContextVar[JobReporter | NullReporter | None] = ContextVar(
"job_reporter", default=None
)
class JobReporter:
def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None:
self.host = host
self.job_id = job_id
self.task_id = task_id
self.sequence = 0
self.final_body: str | None = None
async def progress(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_runtime_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
def finish(self, body: str) -> None:
self.final_body = body
async def _emit(self, event) -> None:
self.sequence += 1
await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event)
def bind_reporter(
reporter: JobReporter,
) -> Token[JobReporter | NullReporter | None]:
return _current.set(reporter)
def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None:
_current.reset(token)
def reporter() -> JobReporter | NullReporter:
return _current.get() or NullReporter()
-33
View File
@@ -1,33 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID, uuid5
from agentci.adapters.job_store import EvolveResult, JobStore
from agentci.domain.events import JobEvent
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ReceiveResult:
state: JobState
duplicate: bool
class StateMachine:
def __init__(self, store: JobStore) -> None:
self.store = store
async def receive(self, incoming: CommandEvent) -> ReceiveResult:
job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id))
result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming)
return ReceiveResult(result.state, result.duplicate)
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
return await self.store.evolve(event_id, event)
async def get(self, job_id: str) -> JobState | None:
return await self.store.get_job_state(job_id)
-215
View File
@@ -1,215 +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,
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.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))
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"<!-- agentci:job id={latest.id} -->"
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]
+1 -1
View File
@@ -1 +1 @@
"""OpenCode workflow orchestration.""" """Functional workflow orchestration."""
-57
View File
@@ -1,57 +0,0 @@
from __future__ import annotations
from pathlib import Path
from agentci.domain.models import AgentResult, Job
from agentci.reporting import reporter
from agentci.workflows.common import Dependencies, JobRejected
class ChangeSet:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
async def commit_and_push(
self,
job: Job,
workspace: Path,
branch: str,
result: AgentResult,
*,
set_upstream: bool,
commit_prefix: str,
) -> str:
await reporter().progress("validating changes")
if not await self.deps.git.has_changes(workspace):
raise JobRejected("OpenCode completed without producing any file changes.")
await self.deps.git.diff_check(workspace)
title = _commit_title(result.summary_markdown)
await reporter().progress("committing changes")
sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}")
await reporter().progress("pushing changes")
await self.deps.git.push(workspace, branch, set_upstream=set_upstream)
return sha
def pull_request_body(issue_number: int, result: AgentResult) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
return (
f"Closes #{issue_number}\n\n"
f"## Implementation\n\n{result.summary_markdown}\n\n"
f"## Validation\n\n{tests}\n\n"
"_Created by Agent CI._"
)
def result_comment(result: AgentResult, *, sha: str | None = None) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
commit = f"\n\nCommit: `{sha}`" if sha else ""
return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}"
def _commit_title(markdown: str) -> str:
for line in markdown.splitlines():
value = line.strip().lstrip("#").strip()
if value:
return value[:72]
return "apply requested changes"
-101
View File
@@ -1,101 +0,0 @@
from __future__ import annotations
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
from agentci.reporting import reporter
from agentci.workflows.common import (
Dependencies,
report_for_prompt,
report_json,
required_session,
)
class CodeReviewLoop:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
async def run(
self,
job: Job,
workflow: Workflow,
issue_context: str,
plan: str,
result: AgentResult,
) -> tuple[AgentResult, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.implement_review_rounds):
await reporter().progress(
f"reviewing implementation {round_index + 1}/"
f"{self.deps.settings.implement_review_rounds}"
)
report = await self.once(
workflow,
issue_context=issue_context,
plan=plan,
pull_context=(
"The proposed pull request is the current uncommitted working-tree diff. "
"Review only that diff."
),
)
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == self.deps.settings.implement_review_rounds - 1:
break
prompt = self.deps.prompts.render(
"implementation_revision",
review=report_for_prompt(workflow.review_json),
development_environment=self.deps.development.description,
)
result = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
return result, report
async def once(
self,
workflow: Workflow,
*,
issue_context: str,
plan: str,
pull_context: str,
) -> ReviewReport:
prompt = self.deps.prompts.render(
"implementation_review",
issue_context=issue_context,
artifact=plan,
pull_context=pull_context,
)
if workflow.reviewer_session_id:
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id = await self.deps.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return report
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.domain.models import ReviewReport
from agentci.prompts import PromptLibrary
from agentci.reporting import reporter
from agentci.workflows.context import ContextBuilder
class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
@dataclass(frozen=True)
class Dependencies:
settings: Settings
storage: Storage
gitea: GiteaClient
git: GitClient
opencode: OpenCodeClient
prompts: PromptLibrary
context: ContextBuilder
development: DevelopmentEnvironment
def review_markdown(report: ReviewReport) -> str:
if not report.findings:
return ""
lines = ["## Remaining review findings", "", report.summary]
for finding in report.findings:
location = f" — `{finding.location}`" if finding.location else ""
lines.extend(
[
"",
f"### {finding.severity.value.upper()}: {finding.title}{location}",
finding.detail,
"",
f"Recommendation: {finding.recommendation}",
]
)
return "\n".join(lines)
def report_json(report: ReviewReport) -> str:
return report.model_dump_json()
def report_for_prompt(report_json_value: str | None) -> str:
if not report_json_value:
return "(none)"
try:
return json.dumps(json.loads(report_json_value), indent=2)
except json.JSONDecodeError:
return report_json_value
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
async def finish_job(body: str) -> None:
reporter().finish(body)
+91 -68
View File
@@ -1,81 +1,104 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any from typing import Any
from agentci.adapters.gitea import GiteaClient from agentci.engine.repository import Repository
from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo from agentci.integrations.gitea.client import Gitea
from agentci.adapters.storage import Storage from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo
class ContextBuilder: async def build_issue_context(
def __init__(self, gitea: GiteaClient, storage: Storage) -> None: gitea: Gitea,
self.gitea = gitea repository: Repository,
self.storage = storage owner: str,
repo: str,
number: int,
) -> str:
issue, comments, operational = await asyncio.gather(
gitea.issue(owner, repo, number),
gitea.issue_comments(owner, repo, number),
repository.operational_comment_ids(owner, repo, number),
)
discussion = "\n\n".join(
_format_comment(comment) for comment in comments if comment.id not in operational
)
return (
f"Repository: {owner}/{repo}\n"
f"Issue: #{number} \u2014 {issue.title}\n"
f"State: {issue.state}\n\n"
f"## Issue body\n{issue.body or '(empty)'}\n\n"
f"## Discussion\n{discussion or '(none)'}"
)
async def issue_context(self, owner: str, repo: str, number: int) -> str:
issue = await self.gitea.issue(owner, repo, number) async def build_pull_request_context(
comments = await self.gitea.issue_comments(owner, repo, number) gitea: Gitea,
operational = await self.storage.operational_comment_ids(owner, repo, number) owner: str,
discussion = "\n\n".join( repo: str,
_format_comment(comment) for comment in comments if comment.id not in operational number: int,
) -> tuple[PullRequestInfo, str]:
pull, timeline, reviews, commits = await asyncio.gather(
gitea.pull_request(owner, repo, number),
gitea.issue_comments(owner, repo, number),
gitea.pull_reviews(owner, repo, number),
gitea.pull_commits(owner, repo, number),
)
review_text = await _format_reviews(gitea, owner, repo, number, reviews)
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
commit_text = "\n".join(
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
for item in commits
)
context = (
f"Repository: {owner}/{repo}\n"
f"Pull request: #{number} \u2014 {pull.title}\n"
f"State: {pull.state}; merged: {pull.merged}\n"
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
f"{pull.head_branch} @ {pull.head_sha}\n\n"
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
f"## Commits\n{commit_text or '(none)'}\n\n"
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
f"## Formal and inline reviews\n{review_text or '(none)'}"
)
return pull, context
async def _format_reviews(
gitea: Gitea,
owner: str,
repo: str,
number: int,
reviews: list[dict[str, Any]],
) -> str:
details = [
(
int(review["id"]),
review.get("user", {}).get("login", "unknown"),
review.get("state", "unknown"),
review.get("body") or "(empty)",
) )
return ( for review in reviews
f"Repository: {owner}/{repo}\n" ]
f"Issue: #{number}{issue.title}\n" comment_groups = await asyncio.gather(
f"State: {issue.state}\n\n" *(
f"## Issue body\n{issue.body or '(empty)'}\n\n" gitea.review_comments(owner, repo, number, review_id)
f"## Discussion\n{discussion or '(none)'}" for review_id, _, _, _ in details
) )
)
async def pull_request_context( sections: list[str] = []
self, owner: str, repo: str, number: int for (review_id, author, state, body), comments in zip(
) -> tuple[PullRequestInfo, str]: details, comment_groups, strict=True
pull = await self.gitea.pull_request(owner, repo, number) ):
timeline = await self.gitea.issue_comments(owner, repo, number) lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
reviews = await self.gitea.pull_reviews(owner, repo, number) for comment in comments:
commits = await self.gitea.pull_commits(owner, repo, number) path = comment.get("path") or "unknown file"
review_text = await self._format_reviews(owner, repo, number, reviews) line = comment.get("new_position") or comment.get("old_position") or "?"
timeline_text = "\n\n".join(_format_comment(item) for item in timeline) text = comment.get("body") or ""
commit_text = "\n".join( lines.append(f"- `{path}:{line}`: {text}")
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}" sections.append("\n".join(lines))
for item in commits return "\n\n".join(sections)
)
context = (
f"Repository: {owner}/{repo}\n"
f"Pull request: #{number}{pull.title}\n"
f"State: {pull.state}; merged: {pull.merged}\n"
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
f"{pull.head_branch} @ {pull.head_sha}\n\n"
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
f"## Commits\n{commit_text or '(none)'}\n\n"
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
f"## Formal and inline reviews\n{review_text or '(none)'}"
)
return pull, context
async def _format_reviews(
self,
owner: str,
repo: str,
number: int,
reviews: list[dict[str, Any]],
) -> str:
sections: list[str] = []
for review in reviews:
review_id = int(review["id"])
author = review.get("user", {}).get("login", "unknown")
state = review.get("state", "unknown")
body = review.get("body") or "(empty)"
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
for comment in await self.gitea.review_comments(owner, repo, number, review_id):
path = comment.get("path") or "unknown file"
line = comment.get("new_position") or comment.get("old_position") or "?"
text = comment.get("body") or ""
lines.append(f"- `{path}:{line}`: {text}")
sections.append("\n".join(lines))
return "\n\n".join(sections)
def _format_comment(comment: CommentInfo) -> str: def _format_comment(comment: CommentInfo) -> str:
return f"### {comment.author} at {comment.created_at}\n{comment.body}" return f"### {comment.author} at {comment.created_at}\n{comment.body}"
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import logging
from agentci.engine.model import Job, JobKind
from agentci.engine.run import JobRun
from agentci.workflows.implementation import implement
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__)
async def dispatch(job: Job, run: JobRun, services: WorkflowServices) -> str:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
"target": job.target_key,
"stage": job.kind.value,
}
log.info("workflow dispatch started", extra=extra)
try:
match job.kind:
case JobKind.PLAN:
body = await create_plan(job, run, services)
case JobKind.DISCUSS:
body = await discuss_plan(job, run, services)
case JobKind.ITERATE_PLAN:
body = await iterate_plan(job, run, services)
case JobKind.IMPLEMENT:
body = await implement(job, run, services)
case JobKind.ITERATE_IMPLEMENT:
body = await iterate_implementation(job, run, services)
case JobKind.FIX:
body = await fix_pull_request(job, run, services)
case _:
raise KeyError(job.kind)
except Exception:
log.exception("workflow dispatch failed", extra=extra)
raise
log.info("workflow dispatch completed", extra=extra)
return body
-44
View File
@@ -1,44 +0,0 @@
from __future__ import annotations
import logging
from typing import Any
from agentci.domain.models import JobKind
from agentci.domain.state_machine import JobState
from agentci.workflows.common import Dependencies
from agentci.workflows.implement import ImplementWorkflow
from agentci.workflows.plan import PlanWorkflow
from agentci.workflows.pull_request import PullRequestWorkflow
log = logging.getLogger(__name__)
class Dispatcher:
def __init__(self, dependencies: Dependencies) -> None:
plan = PlanWorkflow(dependencies)
pull_request = PullRequestWorkflow(dependencies)
self.handlers: dict[JobKind, Any] = {
JobKind.PLAN: plan.plan,
JobKind.DISCUSS: plan.discuss,
JobKind.ITERATE_PLAN: plan.iterate,
JobKind.IMPLEMENT: ImplementWorkflow(dependencies).run,
JobKind.ITERATE_IMPLEMENT: pull_request.iterate,
JobKind.FIX: pull_request.fix,
}
async def dispatch(self, job: JobState) -> None:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
"target": job.target_key,
"stage": job.kind.value,
}
log.info("workflow dispatch started", extra=extra)
try:
await self.handlers[job.kind](job)
except Exception:
log.exception("workflow dispatch failed", extra=extra)
raise
log.info("workflow dispatch completed", extra=extra)
-148
View File
@@ -1,148 +0,0 @@
from __future__ import annotations
from uuid import uuid4
from agentci.domain.models import (
AgentResult,
Job,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment
from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import (
Dependencies,
JobRejected,
agent_comment,
finish_job,
report_json,
review_markdown,
)
class ImplementWorkflow:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
self.review = CodeReviewLoop(dependencies)
self.changes = ChangeSet(dependencies)
async def run(self, job: Job) -> None:
await self._reject_duplicate(job)
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
issue = await self.deps.gitea.issue(job.repo_owner, job.repo_name, job.issue_number)
workflow_id = str(uuid4())
branch = (
f"{self.deps.settings.branch_prefix}/issue-{job.issue_number}-"
f"{workflow_id[:8]}"
)
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
await reporter().progress("cloning")
base_sha = await self.deps.git.clone(
job.repo_owner,
job.repo_name,
repository.default_branch,
workspace,
)
await self.deps.git.create_branch(workspace, branch)
workflow = Workflow(
id=workflow_id,
kind=WorkflowKind.IMPLEMENT,
repo_owner=job.repo_owner,
repo_name=job.repo_name,
issue_number=job.issue_number,
workspace_path=workspace,
base_sha=base_sha,
branch=branch,
)
await reporter().create_workflow(workflow, "installing development environment")
await self.deps.development.prepare(workspace)
await reporter().progress("implementing")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
plan = await self.deps.storage.latest_workflow(
job.repo_owner, job.repo_name, job.issue_number, WorkflowKind.PLAN
)
prompt = self.deps.prompts.render(
"implement_initial",
context=context,
artifact=plan.artifact if plan and plan.artifact else "(no canonical plan)",
request=job.message or "(no additional request)",
development_environment=self.deps.development.description,
)
session_id = await self.deps.opencode.create_session(workspace, "implementation")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await reporter().link_runtime_session(session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow.artifact = result.model_dump_json()
await self.deps.storage.update_workflow(workflow)
result, report = await self.review.run(
job,
workflow,
context,
plan.artifact if plan and plan.artifact else "(no canonical plan)",
result,
)
sha = await self.changes.commit_and_push(
job,
workspace,
branch,
result,
set_upstream=True,
commit_prefix="agent",
)
await reporter().progress("creating pull request")
pull = await self.deps.gitea.create_pull_request(
job.repo_owner,
job.repo_name,
title=f"Agent: {issue.title}",
body=pull_request_body(job.issue_number, result),
head=branch,
base=repository.default_branch,
)
workflow.pr_number = pull.number
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow)
pull_url = (
f"{self.deps.settings.gitea_url}/{job.repo_owner}/"
f"{job.repo_name}/pulls/{pull.number}"
)
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
body = agent_comment("implementation", workflow.id, body)
remaining = review_markdown(report)
if remaining:
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def _reject_duplicate(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await self.deps.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open:
raise JobRejected(
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
"on that pull request."
)
if pull.merged:
raise JobRejected(
f"Agent PR #{pull.number} has already been merged for this issue."
)
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from uuid import uuid4
from agentci.engine.model import Job, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.workflows.context import build_issue_context
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected,
commit_title,
final_comment,
pull_request_body,
result_comment,
)
from agentci.workflows.review import review_implementation_loop
from agentci.workflows.services import WorkflowServices
async def implement(job: Job, run: JobRun, services: WorkflowServices) -> str:
await _reject_duplicate(job, services)
default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
issue = await services.gitea.issue(job.repo_owner, job.repo_name, job.issue_number)
workflow_id = str(uuid4())
branch = (
f"{services.settings.branch_prefix}/issue-{job.issue_number}-"
f"{workflow_id[:8]}"
)
workspace = services.settings.workspaces_dir / workflow_id / "repo"
await run.stage("cloning")
base_sha = await services.git.clone(
job.repo_owner,
job.repo_name,
default_branch,
workspace,
)
await services.git.create_branch(workspace, branch)
workflow = Workflow(
id=workflow_id,
kind=WorkflowKind.IMPLEMENT,
repo_owner=job.repo_owner,
repo_name=job.repo_name,
issue_number=job.issue_number,
workspace_path=workspace,
base_sha=base_sha,
branch=branch,
)
await run.create_workflow(workflow, "installing development environment")
await services.development.prepare(workspace)
await run.stage("implementing")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
plan = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
job.issue_number,
WorkflowKind.PLAN,
)
plan_artifact = plan.artifact if plan and plan.artifact else "(no canonical plan)"
prompt = services.prompts.render(
"implement_initial",
context=context,
artifact=plan_artifact,
request=job.message or "(no additional request)",
development_environment=services.development.description,
)
session_id = await services.opencode.create_session(workspace, "implementation")
workflow = replace(workflow, primary_session_id=session_id)
await services.repository.save_workflow(workflow)
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow = replace(workflow, artifact=result.model_dump_json())
await services.repository.save_workflow(workflow)
workflow, result, report = await review_implementation_loop(
workflow,
context,
plan_artifact,
result,
run,
services,
)
sha = await commit_and_push(
run,
services,
workspace,
branch,
result,
set_upstream=True,
commit_prefix="agent",
)
await run.stage("creating pull request")
pull = await services.gitea.create_pull_request(
job.repo_owner,
job.repo_name,
title=f"Agent: {issue.title}",
body=pull_request_body(job.issue_number, result),
head=branch,
base=default_branch,
)
workflow = replace(
workflow,
pr_number=pull.number,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
pull_url = (
f"{services.settings.gitea_url}/{job.repo_owner}/"
f"{job.repo_name}/pulls/{pull.number}"
)
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
return final_comment("implementation", workflow.id, body, report)
async def commit_and_push(
run: JobRun,
services: WorkflowServices,
workspace: Path,
branch: str,
result: AgentResult,
*,
set_upstream: bool,
commit_prefix: str,
) -> str:
await run.stage("validating changes")
if not await services.git.has_changes(workspace):
raise JobRejected("OpenCode completed without producing any file changes.")
await services.git.diff_check(workspace)
title = commit_title(result.summary_markdown)
await run.stage("committing changes")
sha = await services.git.commit(workspace, f"{commit_prefix}: {title}")
await run.stage("pushing changes")
await services.git.push(workspace, branch, set_upstream=set_upstream)
return sha
async def _reject_duplicate(job: Job, services: WorkflowServices) -> None:
workflows = await services.repository.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await services.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open:
raise JobRejected(
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
"on that pull request."
)
if pull.merged:
raise JobRejected(
f"Agent PR #{pull.number} has already been merged for this issue."
)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, Field
class ReviewSeverity(StrEnum):
BLOCKING = "blocking"
MAJOR = "major"
MINOR = "minor"
class PlanArtifact(BaseModel):
plan_markdown: str = Field(min_length=1)
class DiscussionReply(BaseModel):
markdown: str = Field(min_length=1)
class AgentResult(BaseModel):
summary_markdown: str = Field(min_length=1)
tests: list[str] = Field(default_factory=list)
class ReviewFinding(BaseModel):
severity: ReviewSeverity
title: str
detail: str
location: str | None = None
recommendation: str
class ReviewReport(BaseModel):
summary: str
findings: list[ReviewFinding] = Field(default_factory=list)
@property
def has_serious_findings(self) -> bool:
return any(
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
for finding in self.findings
)
+170 -216
View File
@@ -1,235 +1,189 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from uuid import uuid4 from uuid import uuid4
from agentci.domain.models import ( from agentci.engine.model import (
DiscussionReply,
Job, Job,
PlanArtifact,
ReviewReport,
Workflow, Workflow,
WorkflowKind, WorkflowKind,
WorkflowStatus, WorkflowStatus,
) )
from agentci.reporting import reporter from agentci.engine.run import JobRun
from agentci.workflows.common import ( from agentci.workflows.context import build_issue_context
Dependencies, from agentci.workflows.model import DiscussionReply, PlanArtifact
from agentci.workflows.render import (
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job, final_comment,
report_for_prompt, report_for_prompt,
report_json,
required_session,
review_markdown,
) )
from agentci.workflows.review import review_plan_loop, review_plan_once
from agentci.workflows.services import WorkflowServices
class PlanWorkflow: async def create_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
def __init__(self, dependencies: Dependencies) -> None: default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
self.deps = dependencies workflow_id = str(uuid4())
workspace = services.settings.workspaces_dir / workflow_id / "repo"
async def plan(self, job: Job) -> None: await run.stage("cloning")
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name) base_sha = await services.git.clone(
workflow_id = str(uuid4()) job.repo_owner,
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo" job.repo_name,
await reporter().progress("cloning") default_branch,
base_sha = await self.deps.git.clone( workspace,
job.repo_owner, )
job.repo_name, workflow = Workflow(
repository.default_branch, id=workflow_id,
workspace, kind=WorkflowKind.PLAN,
) repo_owner=job.repo_owner,
workflow = Workflow( repo_name=job.repo_name,
id=workflow_id, issue_number=job.issue_number,
kind=WorkflowKind.PLAN, workspace_path=workspace,
repo_owner=job.repo_owner, base_sha=base_sha,
repo_name=job.repo_name, )
issue_number=job.issue_number, await run.create_workflow(workflow, "planning")
workspace_path=workspace,
base_sha=base_sha,
)
await reporter().create_workflow(workflow, "planning")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
prompt = self.deps.prompts.render(
"plan_initial",
context=context,
request=job.message or "(no additional request)",
)
session_id = await self.deps.opencode.create_session(workspace, "plan")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await reporter().link_runtime_session(session_id)
artifact = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow.artifact = artifact.plan_markdown
await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
async def discuss(self, job: Job) -> None: context = await build_issue_context(
workflow = await self._latest_plan(job) services.gitea,
if workflow.runtime != "opencode": services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
prompt = services.prompts.render(
"plan_initial",
context=context,
request=job.message or "(no additional request)",
)
session_id = await services.opencode.create_session(workspace, "plan")
workflow = replace(workflow, primary_session_id=session_id)
await services.repository.save_workflow(workflow)
await run.link_session(session_id)
artifact = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow = replace(workflow, artifact=artifact.plan_markdown)
await services.repository.save_workflow(workflow)
workflow, artifact, report = await review_plan_loop(
workflow, context, artifact, run, services
)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
async def discuss_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
workflow = await _latest_plan(job, services)
if workflow.runtime != "opencode":
raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; "
"start a new `/agent plan`."
)
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await run.link_workflow(workflow.id, "discussing")
prompt = services.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message or ""
)
reply = await services.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
return agent_comment("discussion", workflow.id, reply.markdown)
async def iterate_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
await _reject_if_active_or_merged_pr(job, services)
workflow = await _latest_plan(job, services)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
raise JobRejected("The latest plan has no saved artifact.")
await run.link_workflow(workflow.id, "iterating plan")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
prompt = services.prompts.render(
"plan_iterate",
context=context,
artifact=workflow.artifact,
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await services.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow, report = await review_plan_once(workflow, context, artifact, services)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow:
workflow = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
job.issue_number,
WorkflowKind.PLAN,
)
if workflow is None:
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
return workflow
async def _reject_if_active_or_merged_pr(
job: Job, services: WorkflowServices
) -> None:
workflows = await services.repository.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await services.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open or pull.merged:
raise JobRejected( raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; " f"Issue plan iteration is disabled because agent PR #{pull.number} "
"start a new `/agent plan`." "is open or merged. Iterate an open implementation on its PR."
) )
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await reporter().link_workflow(workflow.id, "discussing")
prompt = self.deps.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message
)
reply = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
await finish_job(agent_comment("discussion", workflow.id, reply.markdown))
async def iterate(self, job: Job) -> None:
await self._reject_if_active_or_merged_pr(job)
workflow = await self._latest_plan(job)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
raise JobRejected("The latest plan has no saved artifact.")
await reporter().link_workflow(workflow.id, "iterating plan")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
prompt = self.deps.prompts.render(
"plan_iterate",
context=context,
artifact=workflow.artifact,
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
report = await self._review(workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
async def _review_loop(
self, job: Job, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.plan_review_rounds):
await reporter().progress(
f"reviewing plan {round_index + 1}/{self.deps.settings.plan_review_rounds}"
)
report = await self._review(workflow, context, artifact)
workflow.artifact = artifact.plan_markdown
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == self.deps.settings.plan_review_rounds - 1:
break
prompt = self.deps.prompts.render(
"plan_revision",
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
return report
async def _review(
self, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
prompt = self.deps.prompts.render(
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review")
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.plan_model,
variant=self.deps.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return report
async def _finish(
self,
job: Job,
workflow: Workflow,
artifact: PlanArtifact,
report: ReviewReport,
) -> None:
workflow.artifact = artifact.plan_markdown
workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow)
body = agent_comment("plan", workflow.id, artifact.plan_markdown)
remaining = review_markdown(report)
if remaining:
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def _latest_plan(self, job: Job) -> Workflow:
workflow = await self.deps.storage.latest_workflow(
job.repo_owner, job.repo_name, job.issue_number, WorkflowKind.PLAN
)
if workflow is None:
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
return workflow
async def _reject_if_active_or_merged_pr(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await self.deps.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open or pull.merged:
raise JobRejected(
f"Issue plan iteration is disabled because agent PR #{pull.number} "
"is open or merged. Iterate an open implementation on its PR."
)
+147 -129
View File
@@ -1,142 +1,160 @@
from __future__ import annotations from __future__ import annotations
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus from dataclasses import replace
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, result_comment from agentci.engine.model import Job, WorkflowKind, WorkflowStatus
from agentci.workflows.code_review import CodeReviewLoop from agentci.engine.run import JobRun
from agentci.workflows.common import ( from agentci.workflows.context import (
Dependencies, build_issue_context,
build_pull_request_context,
)
from agentci.workflows.implementation import commit_and_push
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job, final_comment,
report_for_prompt, report_for_prompt,
report_json, result_comment,
review_markdown,
) )
from agentci.workflows.review import review_implementation_once
from agentci.workflows.services import WorkflowServices
class PullRequestWorkflow: async def iterate_implementation(
def __init__(self, dependencies: Dependencies) -> None: job: Job, run: JobRun, services: WorkflowServices
self.deps = dependencies ) -> str:
self.review = CodeReviewLoop(dependencies) pull_number = _pull_number(job)
self.changes = ChangeSet(dependencies) workflow = await services.repository.workflow_for_pr(
job.repo_owner, job.repo_name, pull_number
)
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED:
raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
async def iterate(self, job: Job) -> None: pull, context = await build_pull_request_context(
pull_number = _pull_number(job) services.gitea, job.repo_owner, job.repo_name, pull_number
workflow = await self.deps.storage.workflow_for_pr( )
job.repo_owner, job.repo_name, pull_number if not pull.is_open:
) raise JobRejected("Implementation iteration requires an open pull request.")
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED: if workflow.branch != pull.head_branch:
raise JobRejected( raise JobRejected("The pull request head branch no longer matches its workflow.")
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
pull, context = await self.deps.context.pull_request_context(
job.repo_owner, job.repo_name, pull_number
)
if not pull.is_open:
raise JobRejected("Implementation iteration requires an open pull request.")
if workflow.branch != pull.head_branch:
raise JobRejected("The pull request head branch no longer matches its workflow.")
await reporter().link_workflow(workflow.id, "synchronizing branch")
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch)
await reporter().progress("installing development environment")
await self.deps.development.prepare(workflow.workspace_path)
await reporter().progress("implementing iteration")
prompt = self.deps.prompts.render(
"implementation_iterate",
context=context,
review=report_for_prompt(workflow.review_json),
message=job.message or "(perform one additional reviewed refinement)",
development_environment=self.deps.development.description,
)
result = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
issue_context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, workflow.issue_number
)
plan = await self.deps.storage.latest_workflow(
job.repo_owner, job.repo_name, workflow.issue_number, WorkflowKind.PLAN
)
report = await self.review.once(
workflow,
issue_context=issue_context,
plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
pull_context=context,
)
sha = await self.changes.commit_and_push(
job,
workflow.workspace_path,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent iterate",
)
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
body = agent_comment(
"iteration", workflow.id, result_comment(result, sha=sha)
)
remaining = review_markdown(report)
if remaining:
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def fix(self, job: Job) -> None: await run.link_workflow(workflow.id, "synchronizing branch")
pull_number = _pull_number(job) await services.git.sync_branch(workflow.workspace_path, pull.head_branch)
pull, context = await self.deps.context.pull_request_context( await run.stage("installing development environment")
job.repo_owner, job.repo_name, pull_number await services.development.prepare(workflow.workspace_path)
) await run.stage("implementing iteration")
if not pull.is_open: prompt = services.prompts.render(
raise JobRejected("Fixes require an open pull request.") "implementation_iterate",
workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo" context=context,
await reporter().progress("cloning pull request") review=report_for_prompt(workflow.review_json),
await self.deps.git.clone( message=job.message or "(perform one additional reviewed refinement)",
pull.head_owner, development_environment=services.development.description,
pull.head_repo, )
pull.head_branch, result = await services.opencode.resume(
workspace, session_id=workflow.primary_session_id,
) prompt=prompt,
await reporter().progress("installing development environment") model=services.settings.implement_model,
await self.deps.development.prepare(workspace) variant=services.settings.implement_variant,
prompt = self.deps.prompts.render( workspace=workflow.workspace_path,
"fix", schema_name="agent_result.json",
context=context, result_type=AgentResult,
message=job.message or "(address the pull request feedback)", )
development_environment=self.deps.development.description,
) issue_context = await build_issue_context(
await reporter().progress("fixing") services.gitea,
session_id = await self.deps.opencode.create_session(workspace, "fix") services.repository,
await reporter().link_runtime_session(session_id) job.repo_owner,
result = await self.deps.opencode.resume( job.repo_name,
session_id=session_id, workflow.issue_number,
workspace=workspace, )
prompt=prompt, plan = await services.repository.latest_workflow(
model=self.deps.settings.implement_model, job.repo_owner,
variant=self.deps.settings.implement_variant, job.repo_name,
schema_name="agent_result.json", workflow.issue_number,
result_type=AgentResult, WorkflowKind.PLAN,
) )
sha = await self.changes.commit_and_push( workflow, report = await review_implementation_once(
job, workflow,
workspace, issue_context=issue_context,
pull.head_branch, plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
result, pull_context=context,
set_upstream=False, services=services,
commit_prefix="agent fix", )
) sha = await commit_and_push(
await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha))) run,
services,
workflow.workspace_path,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent iterate",
)
workflow = replace(
workflow,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
return final_comment(
"iteration", workflow.id, result_comment(result, sha=sha), report
)
async def fix_pull_request(
job: Job, run: JobRun, services: WorkflowServices
) -> str:
pull_number = _pull_number(job)
pull, context = await build_pull_request_context(
services.gitea, job.repo_owner, job.repo_name, pull_number
)
if not pull.is_open:
raise JobRejected("Fixes require an open pull request.")
workspace = services.settings.workspaces_dir / f"fix-{job.id}" / "repo"
await run.stage("cloning pull request")
await services.git.clone(
pull.head_owner,
pull.head_repo,
pull.head_branch,
workspace,
)
await run.stage("installing development environment")
await services.development.prepare(workspace)
prompt = services.prompts.render(
"fix",
context=context,
message=job.message or "(address the pull request feedback)",
development_environment=services.development.description,
)
await run.stage("fixing")
session_id = await services.opencode.create_session(workspace, "fix")
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
sha = await commit_and_push(
run,
services,
workspace,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent fix",
)
return agent_comment("fix", job.id, result_comment(result, sha=sha))
def _pull_number(job: Job) -> int: def _pull_number(job: Job) -> int:
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import json
from agentci.workflows.model import AgentResult, ReviewReport
class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
def review_markdown(report: ReviewReport) -> str:
if not report.findings:
return ""
lines = ["## Remaining review findings", "", report.summary]
for finding in report.findings:
location = f" \u2014 `{finding.location}`" if finding.location else ""
lines.extend(
[
"",
f"### {finding.severity.value.upper()}: {finding.title}{location}",
finding.detail,
"",
f"Recommendation: {finding.recommendation}",
]
)
return "\n".join(lines)
def report_for_prompt(stored_review: str | None) -> str:
if not stored_review:
return "(none)"
try:
return json.dumps(json.loads(stored_review), indent=2)
except json.JSONDecodeError:
return stored_review
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
def final_comment(
kind: str,
workflow_id: str,
body: str,
report: ReviewReport | None = None,
) -> str:
rendered = agent_comment(kind, workflow_id, body)
remaining = review_markdown(report) if report else ""
return f"{rendered}\n\n{remaining}" if remaining else rendered
def pull_request_body(issue_number: int, result: AgentResult) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
return (
f"Closes #{issue_number}\n\n"
f"## Implementation\n\n{result.summary_markdown}\n\n"
f"## Validation\n\n{tests}\n\n"
"_Created by Agent CI._"
)
def result_comment(result: AgentResult, *, sha: str | None = None) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
commit = f"\n\nCommit: `{sha}`" if sha else ""
return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}"
def commit_title(markdown: str) -> str:
for line in markdown.splitlines():
value = line.strip().lstrip("#").strip()
if value:
return value[:72]
return "apply requested changes"
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from dataclasses import replace
from agentci.engine.model import Workflow
from agentci.engine.run import JobRun
from agentci.workflows.model import AgentResult, PlanArtifact, ReviewReport
from agentci.workflows.render import report_for_prompt, required_session
from agentci.workflows.services import WorkflowServices
async def review_plan_loop(
workflow: Workflow,
context: str,
artifact: PlanArtifact,
run: JobRun,
services: WorkflowServices,
) -> tuple[Workflow, PlanArtifact, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(services.settings.plan_review_rounds):
await run.stage(
f"reviewing plan {round_index + 1}/{services.settings.plan_review_rounds}"
)
workflow, report = await review_plan_once(
workflow, context, artifact, services
)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == services.settings.plan_review_rounds - 1:
break
prompt = services.prompts.render(
"plan_revision",
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await services.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow = replace(workflow, artifact=artifact.plan_markdown)
await services.repository.save_workflow(workflow)
return workflow, artifact, report
async def review_plan_once(
workflow: Workflow,
context: str,
artifact: PlanArtifact,
services: WorkflowServices,
) -> tuple[Workflow, ReviewReport]:
prompt = services.prompts.render(
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
report = await services.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
session_id = await services.opencode.create_session(
workflow.workspace_path, "plan-review"
)
workflow = replace(workflow, reviewer_session_id=session_id)
await services.repository.save_workflow(workflow)
report = await services.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
async def review_implementation_loop(
workflow: Workflow,
issue_context: str,
plan: str,
result: AgentResult,
run: JobRun,
services: WorkflowServices,
) -> tuple[Workflow, AgentResult, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(services.settings.implement_review_rounds):
await run.stage(
f"reviewing implementation {round_index + 1}/"
f"{services.settings.implement_review_rounds}"
)
workflow, report = await review_implementation_once(
workflow,
issue_context=issue_context,
plan=plan,
pull_context=(
"The proposed pull request is the current uncommitted working-tree diff. "
"Review only that diff."
),
services=services,
)
workflow = replace(
workflow,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == services.settings.implement_review_rounds - 1:
break
prompt = services.prompts.render(
"implementation_revision",
review=report_for_prompt(workflow.review_json),
development_environment=services.development.description,
)
result = await services.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow = replace(workflow, artifact=result.model_dump_json())
await services.repository.save_workflow(workflow)
return workflow, result, report
async def review_implementation_once(
workflow: Workflow,
*,
issue_context: str,
plan: str,
pull_context: str,
services: WorkflowServices,
) -> tuple[Workflow, ReviewReport]:
prompt = services.prompts.render(
"implementation_review",
issue_context=issue_context,
artifact=plan,
pull_context=pull_context,
)
if workflow.reviewer_session_id:
report = await services.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
session_id = await services.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow = replace(workflow, reviewer_session_id=session_id)
await services.repository.save_workflow(workflow)
report = await services.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from agentci.config.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
+130
View File
@@ -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."}
+348 -89
View File
@@ -1,22 +1,30 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
from agentci.domain.models import ( from pydantic import BaseModel
from agentci.engine.model import Workflow, WorkflowKind
from agentci.engine.run import JobRun
from agentci.workflows.model import (
AgentResult, AgentResult,
Job, PlanArtifact,
JobKind,
ReviewFinding, ReviewFinding,
ReviewReport, ReviewReport,
ReviewSeverity, ReviewSeverity,
Workflow,
WorkflowKind,
) )
from agentci.workflows.code_review import CodeReviewLoop from agentci.workflows.review import (
review_implementation_loop,
review_implementation_once,
review_plan_loop,
review_plan_once,
)
from agentci.workflows.services import WorkflowServices
def serious_report() -> ReviewReport: def serious_report(summary: str = "Needs work") -> ReviewReport:
return ReviewReport( return ReviewReport(
summary="Needs work", summary=summary,
findings=[ findings=[
ReviewFinding( ReviewFinding(
severity=ReviewSeverity.MAJOR, severity=ReviewSeverity.MAJOR,
@@ -28,101 +36,352 @@ def serious_report() -> ReviewReport:
) )
class FakeOpenCode: def clean_report() -> ReviewReport:
def __init__(self, reports: list[ReviewReport]) -> None: return ReviewReport(summary="Ready", findings=[])
self.reports = iter(reports)
self.reviews = 0
self.revisions = 0
async def create_session(self, *_args):
return "reviewer"
async def resume(self, **kwargs):
if kwargs["result_type"] is ReviewReport:
self.reviews += 1
return next(self.reports)
self.revisions += 1
return AgentResult(summary_markdown=f"revision {self.revisions}", tests=[])
class FakeStorage: def minor_report() -> ReviewReport:
async def update_job(self, *_args, **_kwargs): return ReviewReport(
return None summary="Optional improvement",
findings=[
async def update_workflow(self, *_args, **_kwargs): ReviewFinding(
return None severity=ReviewSeverity.MINOR,
title="Clarify wording",
detail="The wording could be clearer.",
class FakePrompts: recommendation="Tighten it when convenient.",
def render(self, name, **_kwargs): )
return name ],
def objects(rounds: int, reports: list[ReviewReport]):
opencode = FakeOpenCode(reports)
settings = SimpleNamespace(
implement_review_rounds=rounds,
implement_model="model",
implement_variant="high",
) )
deps = SimpleNamespace(
settings=settings,
opencode=opencode, class RecordingOpenCode:
storage=FakeStorage(), def __init__(self, responses: list[BaseModel]) -> None:
prompts=FakePrompts(), self.responses = list(responses)
development=SimpleNamespace(description="python"), self.created_sessions: list[tuple[Path, str]] = []
) self.resume_calls: list[dict[str, Any]] = []
workflow = Workflow(
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> BaseModel:
self.resume_calls.append(values)
response = self.responses.pop(0)
assert isinstance(response, values["result_type"])
return response
class RecordingRepository:
def __init__(self) -> None:
self.saved_workflows: list[Workflow] = []
async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow)
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
def workflow(*, reviewer_session_id: str | None = None) -> Workflow:
return Workflow(
id="flow", id="flow",
kind=WorkflowKind.IMPLEMENT, kind=WorkflowKind.IMPLEMENT,
repo_owner="org", repo_owner="org",
repo_name="repo", repo_name="repo",
issue_number=1, issue_number=1,
workspace_path=Path("."), workspace_path=Path("/workspace/repo"),
base_sha="abc", base_sha="abc",
primary_session_id="primary", primary_session_id="primary-session",
reviewer_session_id=reviewer_session_id,
) )
job = Job(
id="job",
kind=JobKind.IMPLEMENT,
target_key="target",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=None,
requester="alice",
message="",
comment_id=1,
)
return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type]
async def test_stops_after_clean_second_review() -> None: def objects(
clean = ReviewReport(summary="Ready", findings=[]) responses: list[BaseModel],
loop, opencode, workflow, job = objects(4, [serious_report(), clean]) *,
_, report = await loop.run( plan_rounds: int = 4,
job, implementation_rounds: int = 3,
workflow, ) -> tuple[
RecordingOpenCode,
RecordingRepository,
RecordingPrompts,
WorkflowServices,
]:
opencode = RecordingOpenCode(responses)
repository = RecordingRepository()
prompts = RecordingPrompts()
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
plan_review_rounds=plan_rounds,
plan_model="provider/plan",
plan_variant="high",
implement_review_rounds=implementation_rounds,
implement_model="provider/implement",
implement_variant="high",
),
opencode=opencode,
repository=repository,
prompts=prompts,
development=SimpleNamespace(description="Python 3.13"),
),
)
return opencode, repository, prompts, services
async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None:
revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"])
opencode, repository, prompts, services = objects(
[serious_report(), revised, clean_report()], implementation_rounds=4
)
run = RecordingRun()
original = workflow()
updated, result, report = await review_implementation_loop(
original,
"issue context", "issue context",
"canonical plan", "canonical plan",
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
run,
services,
) )
assert result == revised
assert report == clean_report()
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "implementation-review-session"
assert updated.artifact == revised.model_dump_json()
assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session")
assert run.stages == [
"reviewing implementation 1/4",
"reviewing implementation 2/4",
]
assert opencode.created_sessions == [(original.workspace_path, "implementation-review")]
assert [call["session_id"] for call in opencode.resume_calls] == [
"implementation-review-session",
"primary-session",
"implementation-review-session",
]
assert [call["result_type"] for call in opencode.resume_calls] == [
ReviewReport,
AgentResult,
ReviewReport,
]
assert [name for name, _ in prompts.calls] == [
"implementation_review",
"implementation_revision",
"implementation_review",
]
assert opencode.responses == []
async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None:
final_report = serious_report("Still failing after the last review")
opencode, repository, _, services = objects(
[
serious_report("round 1"),
AgentResult(summary_markdown="revision 1", tests=[]),
serious_report("round 2"),
AgentResult(summary_markdown="revision 2", tests=[]),
final_report,
],
implementation_rounds=3,
)
run = RecordingRun()
updated, result, report = await review_implementation_loop(
workflow(),
"issue context",
"canonical plan",
AgentResult(summary_markdown="initial", tests=[]),
run,
services,
)
assert result.summary_markdown == "revision 2"
assert report is final_report
assert updated.artifact == result.model_dump_json()
assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls].count(ReviewReport) == 3
assert [call["result_type"] for call in opencode.resume_calls].count(AgentResult) == 2
assert run.stages == [
"reviewing implementation 1/3",
"reviewing implementation 2/3",
"reviewing implementation 3/3",
]
assert opencode.responses == []
async def test_implementation_review_once_reuses_existing_reviewer_session() -> None:
opencode, repository, prompts, services = objects([clean_report()])
existing = workflow(reviewer_session_id="existing-reviewer")
updated, report = await review_implementation_once(
existing,
issue_context="issue context",
plan="canonical plan",
pull_context="pull request context",
services=services,
)
assert updated is existing
assert report == clean_report()
assert opencode.created_sessions == []
assert repository.saved_workflows == []
assert opencode.resume_calls == [
{
"session_id": "existing-reviewer",
"prompt": "rendered implementation_review",
"model": "provider/implement",
"variant": "high",
"workspace": existing.workspace_path,
"schema_name": "review.json",
"result_type": ReviewReport,
}
]
assert prompts.calls == [
(
"implementation_review",
{
"issue_context": "issue context",
"artifact": "canonical plan",
"pull_context": "pull request context",
},
)
]
async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None:
revised = PlanArtifact(plan_markdown="Revised plan")
opencode, repository, prompts, services = objects(
[serious_report(), revised, clean_report()], plan_rounds=4
)
run = RecordingRun()
original = workflow()
initial = PlanArtifact(plan_markdown="Initial plan")
updated, artifact, report = await review_plan_loop(
original, "issue context", initial, run, services
)
assert artifact is revised
assert report == clean_report()
assert updated.reviewer_session_id == "plan-review-session"
assert updated.artifact == "Revised plan"
assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"]
assert [call["session_id"] for call in opencode.resume_calls] == [
"plan-review-session",
"primary-session",
"plan-review-session",
]
assert [name for name, _ in prompts.calls] == [
"plan_review",
"plan_revision",
"plan_review",
]
assert opencode.responses == []
async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None:
final_report = serious_report("round 2")
opencode, repository, _, services = objects(
[
serious_report("round 1"),
PlanArtifact(plan_markdown="Only revision"),
final_report,
],
plan_rounds=2,
)
run = RecordingRun()
updated, artifact, report = await review_plan_loop(
workflow(),
"issue context",
PlanArtifact(plan_markdown="Initial plan"),
run,
services,
)
assert artifact.plan_markdown == "Only revision"
assert report is final_report
assert updated.artifact == "Only revision"
assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [
ReviewReport,
PlanArtifact,
ReviewReport,
]
assert run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"]
assert opencode.responses == []
async def test_plan_review_once_creates_and_persists_reviewer_session() -> None:
opencode, repository, prompts, services = objects([clean_report()])
original = workflow()
updated, report = await review_plan_once(
original,
"issue context",
PlanArtifact(plan_markdown="Plan body"),
services,
)
assert report == clean_report()
assert updated is not original
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "plan-review-session"
assert repository.saved_workflows == [updated]
assert opencode.created_sessions == [(original.workspace_path, "plan-review")]
assert opencode.resume_calls[0]["session_id"] == "plan-review-session"
assert opencode.resume_calls[0]["result_type"] is ReviewReport
assert prompts.calls == [
(
"plan_review",
{"context": "issue context", "artifact": "Plan body"},
)
]
async def test_minor_findings_end_review_loop_without_revision() -> None:
opencode, repository, _, services = objects([minor_report()], implementation_rounds=5)
run = RecordingRun()
initial = AgentResult(summary_markdown="initial", tests=[])
updated, result, report = await review_implementation_loop(
workflow(),
"issue context",
"canonical plan",
initial,
run,
services,
)
assert result is initial
assert report == minor_report()
assert not report.has_serious_findings assert not report.has_serious_findings
assert opencode.reviews == 2 assert updated.artifact == initial.model_dump_json()
assert opencode.revisions == 1 assert updated.review_json == minor_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [ReviewReport]
async def test_does_not_make_unreviewed_final_revision() -> None: assert run.stages == ["reviewing implementation 1/5"]
loop, opencode, workflow, job = objects(
3, [serious_report(), serious_report(), serious_report()]
)
_, report = await loop.run(
job,
workflow,
"issue context",
"canonical plan",
AgentResult(summary_markdown="initial", tests=[]),
)
assert report.has_serious_findings
assert opencode.reviews == 3
assert opencode.revisions == 2
+86 -7
View File
@@ -1,13 +1,17 @@
from pathlib import Path from pathlib import Path
from agentci.adapters.codegraph import CodeGraphClient import pytest
from agentci.integrations.codegraph import CodeGraph, CodeGraphError
class FakeProcess: class FakeProcess:
returncode = 0 def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
self.returncode = returncode
self.stderr = stderr
async def communicate(self) -> tuple[bytes, bytes]: async def communicate(self) -> tuple[bytes, bytes]:
return b"", b"" return b"", self.stderr
async def test_initializes_incomplete_index_and_excludes_it_from_git( async def test_initializes_incomplete_index_and_excludes_it_from_git(
@@ -23,11 +27,11 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
await CodeGraphClient().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "init", str(workspace))] assert calls == [("codegraph", "init", str(workspace))]
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n" assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
@@ -49,11 +53,86 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
await CodeGraphClient().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "sync", str(workspace))] assert calls == [("codegraph", "sync", str(workspace))]
assert exclude.read_text() == "# local excludes\n.codegraph/\n" assert exclude.read_text() == "# local excludes\n.codegraph/\n"
@pytest.mark.parametrize(
("initial", "expected"),
[
("*.pyc", "*.pyc\n.codegraph/\n"),
("# .codegraph/ is documented here\n", "# .codegraph/ is documented here\n.codegraph/\n"),
(" .codegraph/ \n", " .codegraph/ \n"),
],
)
async def test_handles_exclude_file_boundaries(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
initial: str,
expected: str,
) -> None:
workspace = tmp_path / "repo"
exclude = workspace / ".git" / "info" / "exclude"
exclude.parent.mkdir(parents=True)
exclude.write_text(initial)
async def create_subprocess_exec(*_args, **_kwargs):
return FakeProcess()
monkeypatch.setattr(
"agentci.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
+4 -8
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.models import CommandName, JobKind from agentci.engine.model import CommandName, JobKind
def test_ignores_non_commands() -> None: def test_ignores_non_commands() -> None:
@@ -14,8 +14,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
name: CommandName, line_breaks: int name: CommandName, line_breaks: int
) -> None: ) -> None:
command = parse_command( command = parse_command(
f"/agent {name.value}{'\n' * line_breaks}" f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests"
"focus on the API\nand add tests"
) )
assert command is not None assert command is not None
assert command.name is name assert command.name is name
@@ -26,10 +25,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
def test_all_commands_accept_crlf_separated_multiline_messages( def test_all_commands_accept_crlf_separated_multiline_messages(
name: CommandName, name: CommandName,
) -> None: ) -> None:
command = parse_command( command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests")
f"/agent {name.value}\r\n\r\n\r\n"
"focus on the API\r\nand add tests"
)
assert command is not None assert command is not None
assert command.name is name assert command.name is name
assert command.message == "focus on the API\r\nand add tests" assert command.message == "focus on the API\r\nand add tests"
+133 -33
View File
@@ -1,50 +1,150 @@
import os
from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from agentci.config import Settings from agentci.config.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: def test_parses_comma_delimited_install_scripts() -> None:
settings = Settings( value = settings(install_scripts=" python, dotnet, company-tools, ")
_env_file=None, # type: ignore[call-arg]
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"]) @pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None: def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Settings(_env_file=None, install_scripts=value) # type: ignore[call-arg] settings(install_scripts=value)
def test_empty_install_scripts_disable_setup() -> None: @pytest.mark.parametrize("value", ["", None, []])
settings = Settings( def test_empty_install_scripts_disable_setup(value: object) -> None:
_env_file=None, # type: ignore[call-arg] assert settings(install_scripts=value).install_scripts == []
install_scripts="",
def test_agent_defaults_select_expected_capacity_and_research_models() -> None:
value = settings()
assert value.max_concurrent_jobs == 2
assert (value.explore_model, value.explore_variant) == (
"openai/gpt-5.6-luna",
"low",
) )
assert settings.install_scripts == [] assert value.research_variant == "high"
def test_defaults_research_variant_to_high(monkeypatch) -> None:
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.research_variant == "high"
def test_defaults_explore_agent_to_luna_low() -> None:
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.explore_model == "openai/gpt-5.6-luna"
assert settings.explore_variant == "low"
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.install_scripts == ["python", "dotnet"]
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -52,4 +152,4 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) ->
) )
def test_requires_provider_qualified_opencode_models(field: str) -> None: def test_requires_provider_qualified_opencode_models(field: str) -> None:
with pytest.raises(ValidationError, match="provider/model"): with pytest.raises(ValidationError, match="provider/model"):
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg] settings(**{field: "model-only"})
+194 -6
View File
@@ -1,8 +1,12 @@
from agentci.adapters.gitea_models import CommentInfo, IssueInfo from typing import cast
from agentci.workflows.context import ContextBuilder
from agentci.engine.repository import Repository
from agentci.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): async def issue(self, *_args):
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open") return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
@@ -13,15 +17,199 @@ class FakeGitea:
] ]
class FakeStorage: class FakeRepository:
async def operational_comment_ids(self, *_args): async def operational_comment_ids(self, *_args):
return {2} return {2}
class FakePullRequestGitea:
async def pull_request(self, *_args):
return PullRequestInfo(
number=3,
title="Fix widget",
body="Fixes the failure.",
state="open",
merged=False,
base_branch="main",
head_branch="fix-widget",
head_sha="abcdef1234567890",
head_owner="alice",
head_repo="repo",
)
async def issue_comments(self, *_args):
return [CommentInfo(3, "bob", "Please add a test.", "2026-01-03")]
async def pull_reviews(self, *_args):
return [
{
"id": 4,
"user": {"login": "carol"},
"state": "REQUEST_CHANGES",
"body": "One issue remains.",
}
]
async def review_comments(self, *_args):
return [
{
"path": "src/widget.py",
"new_position": 12,
"body": "Handle the empty value.",
}
]
async def pull_commits(self, *_args):
return [{"sha": "abcdef1234567890", "commit": {"message": "Fix widget"}}]
class EmptyIssueGitea:
async def issue(self, *_args):
return IssueInfo(number=5, title="Empty issue", body="", state="open")
async def issue_comments(self, *_args):
return []
class EmptyRepository:
async def operational_comment_ids(self, *_args):
return set()
class EmptyPullRequestGitea:
async def pull_request(self, *_args):
return PullRequestInfo(
number=6,
title="Empty pull request",
body="",
state="open",
merged=False,
base_branch="main",
head_branch="empty",
head_sha="1234567890abcdef",
head_owner="alice",
head_repo="repo",
)
async def issue_comments(self, *_args):
return []
async def pull_reviews(self, *_args):
return []
async def pull_commits(self, *_args):
return []
async def review_comments(self, *_args):
raise AssertionError("review comments should not be requested without reviews")
class OrderedPullRequestGitea(FakePullRequestGitea):
async def issue_comments(self, *_args):
return [
CommentInfo(1, "alice", "First timeline comment", "2026-01-01"),
CommentInfo(2, "bob", "Second timeline comment", "2026-01-02"),
]
async def pull_reviews(self, *_args):
return [
{
"id": 10,
"user": {"login": "carol"},
"state": "REQUEST_CHANGES",
"body": "First formal review",
},
{
"id": 11,
"user": {"login": "dave"},
"state": "APPROVED",
"body": "Second formal review",
},
]
async def review_comments(self, *_args):
review_id = _args[-1]
if review_id == 10:
return [
{
"path": "src/old.py",
"new_position": None,
"old_position": 21,
"body": "Old-side position",
}
]
return [
{
"path": "src/new.py",
"new_position": 34,
"body": "New-side position",
}
]
async def pull_commits(self, *_args):
return [
{"sha": "111111111111aaaa", "commit": {"message": "First commit"}},
{"sha": "222222222222bbbb", "commit": {"message": "Second commit"}},
]
async def test_issue_context_excludes_operational_comments() -> None: async def test_issue_context_excludes_operational_comments() -> None:
builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type] context = await build_issue_context(
context = await builder.issue_context("org", "repo", 2) cast(Gitea, FakeIssueGitea()),
cast(Repository, FakeRepository()),
"org",
"repo",
2,
)
assert "Broken widget" in context assert "Broken widget" in context
assert "Details" in context assert "Details" in context
assert "Agent job queued" not in context assert "Agent job queued" not in context
async def test_pull_request_context_includes_feedback_and_commits() -> None:
pull, context = await build_pull_request_context(
cast(Gitea, FakePullRequestGitea()), "org", "repo", 3
)
assert pull.number == 3
assert "Please add a test." in context
assert "One issue remains." in context
assert "`src/widget.py:12`: Handle the empty value." in context
assert "abcdef123456 Fix widget" in context
async def test_issue_context_labels_empty_body_and_discussion() -> None:
context = await build_issue_context(
cast(Gitea, EmptyIssueGitea()),
cast(Repository, EmptyRepository()),
"org",
"repo",
5,
)
assert "## Issue body\n(empty)" in context
assert "## Discussion\n(none)" in context
async def test_pull_request_context_labels_empty_sections() -> None:
_, context = await build_pull_request_context(
cast(Gitea, EmptyPullRequestGitea()), "org", "repo", 6
)
assert "## Pull request body\n(empty)" in context
assert "## Commits\n(none)" in context
assert "## Timeline discussion\n(none)" in context
assert "## Formal and inline reviews\n(none)" in context
async def test_pull_request_context_preserves_source_order_and_positions() -> None:
_, context = await build_pull_request_context(
cast(Gitea, OrderedPullRequestGitea()), "org", "repo", 3
)
assert context.index("First timeline comment") < context.index("Second timeline comment")
assert context.index("111111111111 First commit") < context.index("222222222222 Second commit")
assert context.index("Review 10 by carol") < context.index("Review 11 by dave")
assert "`src/old.py:21`: Old-side position" in context
assert "`src/new.py:34`: New-side position" in context
+100 -2
View File
@@ -1,8 +1,9 @@
import asyncio
from pathlib import Path from pathlib import Path
import pytest import pytest
from agentci.adapters.development import ( from agentci.integrations.development import (
DevelopmentEnvironment, DevelopmentEnvironment,
DevelopmentEnvironmentError, DevelopmentEnvironmentError,
) )
@@ -36,7 +37,7 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
development = environment(tmp_path, ["first", "second"]) development = environment(tmp_path, ["first", "second"])
script( script(
development.scripts_dir / "first", development.scripts_dir / "first",
"printf 'first:%s:%s\\n' \"$DEV_TOOLS_DIR\" \"${AGENTCI_SECRET-unset}\" >> order", 'printf \'first:%s:%s\\n\' "$DEV_TOOLS_DIR" "${AGENTCI_SECRET-unset}" >> order',
) )
script(development.scripts_dir / "second", "printf 'second\\n' >> order") script(development.scripts_dir / "second", "printf 'second\\n' >> order")
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak") monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
@@ -74,6 +75,33 @@ async def test_runs_non_executable_shell_script_from_bind_mount(tmp_path) -> Non
assert (workspace / "selected").read_text() == "mounted\n" assert (workspace / "selected").read_text() == "mounted\n"
async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None:
development = environment(tmp_path, ["shared"])
script(development.scripts_dir / "shared", "true")
started = asyncio.Event()
release = asyncio.Event()
active = 0
maximum_active = 0
async def run(*_args) -> None:
nonlocal active, maximum_active
active += 1
maximum_active = max(maximum_active, active)
started.set()
await release.wait()
active -= 1
monkeypatch.setattr(development, "_run", run)
first = asyncio.create_task(development.prepare(tmp_path / "first"))
await started.wait()
second = asyncio.create_task(development.prepare(tmp_path / "second"))
await asyncio.sleep(0)
release.set()
await asyncio.gather(first, second)
assert maximum_active == 1
async def test_reports_script_failure_output(tmp_path) -> None: async def test_reports_script_failure_output(tmp_path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
@@ -84,6 +112,76 @@ async def test_reports_script_failure_output(tmp_path) -> None:
await development.prepare(workspace) await development.prepare(workspace)
async def test_reports_missing_install_script(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["missing"])
with pytest.raises(
DevelopmentEnvironmentError,
match=r"Install script 'missing' was not found",
):
await development.prepare(workspace)
async def test_stops_before_second_script_after_failure(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["first", "second"])
script(development.scripts_dir / "first", "printf 'first\n' > first-ran; exit 3")
script(development.scripts_dir / "second", "printf 'second\n' > second-ran")
with pytest.raises(DevelopmentEnvironmentError, match="exited with 3"):
await development.prepare(workspace)
assert (workspace / "first-ran").read_text() == "first\n"
assert not (workspace / "second-ran").exists()
async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["verbose"])
script(
development.scripts_dir / "verbose",
"printf 'discarded-prefix' >&2; "
'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; '
"printf 'useful-tail' >&2; exit 9",
)
with pytest.raises(DevelopmentEnvironmentError) as raised:
await development.prepare(workspace)
message = str(raised.value)
assert "discarded-prefix" not in message
assert message.endswith("useful-tail")
async def test_wraps_subprocess_start_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
development = environment(tmp_path, ["broken"])
script(development.scripts_dir / "broken", "true")
async def create_subprocess_exec(*_args, **_kwargs):
raise OSError("exec unavailable")
monkeypatch.setattr(
"agentci.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: async def test_times_out_install_script(tmp_path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
+247
View File
@@ -0,0 +1,247 @@
import asyncio
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import pytest
from agentci.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")
+335 -12
View File
@@ -1,22 +1,345 @@
import json import json
from collections.abc import AsyncGenerator, Callable, Coroutine
from contextlib import asynccontextmanager
import httpx 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 @asynccontextmanager
async def test_updates_issue_comment_by_id() -> None: async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]:
route = respx.patch( client = Gitea(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17" "https://gitea.example/",
).mock(return_value=httpx.Response(200, json={"id": 17})) "secret",
client = GiteaClient("https://gitea.example", "secret") retries=retries,
transport=httpx.MockTransport(handler),
)
try: try:
await client.update_comment("org", "repo", 17, "updated status") yield client
finally: finally:
await client.close() await client.close()
assert route.called
assert json.loads(route.calls[0].request.content) == {"body": "updated status"} async def test_sends_authenticated_json_request_contract() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"id": 17})
async with gitea_client(handler) as client:
assert await client.update_comment("org", "repo", 17, "updated status")
assert len(requests) == 1
request = requests[0]
assert request.method == "PATCH"
assert request.url == httpx.URL(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
)
assert request.headers["authorization"] == "token secret"
assert request.headers["accept"] == "application/json"
assert request.headers["content-type"] == "application/json"
assert json.loads(request.content) == {"body": "updated status"}
@pytest.mark.parametrize(
("permission", "expected"),
[("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)],
)
async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission"
return httpx.Response(200, json={"permission": permission})
async with gitea_client(handler) as client:
assert await client.has_write_permission("org", "repo", "alice") is expected
async def test_maps_issue_and_pull_request_responses() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
match request.url.path:
case "/api/v1/repos/org/repo":
return httpx.Response(200, json={"default_branch": "trunk"})
case "/api/v1/repos/org/repo/issues/12":
return httpx.Response(
200,
json={"title": "Issue title", "body": None, "state": "open"},
)
case "/api/v1/repos/org/repo/pulls/8":
return httpx.Response(
200,
json={
"title": "Pull title",
"body": None,
"state": "open",
"merged": False,
"base": {"ref": "trunk"},
"head": {
"ref": "feature",
"sha": "abc123",
"repo": {"owner": {"login": "fork-owner"}, "name": "fork"},
},
},
)
raise AssertionError(f"unexpected request: {request.url}")
async with gitea_client(handler) as client:
branch = await client.default_branch("org", "repo")
issue = await client.issue("org", "repo", 12)
pull = await client.pull_request("org", "repo", 8)
assert branch == "trunk"
assert issue == IssueInfo(number=12, title="Issue title", body="", state="open")
assert pull == PullRequestInfo(
number=8,
title="Pull title",
body="",
state="open",
merged=False,
base_branch="trunk",
head_branch="feature",
head_sha="abc123",
head_owner="fork-owner",
head_repo="fork",
)
assert pull.is_open
async def test_creates_comment_and_pull_request_with_expected_payloads() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/issues/4/comments"):
return httpx.Response(201, json={"id": "23"})
if request.method == "POST" and request.url.path.endswith("/pulls"):
return httpx.Response(201, json={"number": 9})
if request.method == "GET" and request.url.path.endswith("/pulls/9"):
return httpx.Response(
200,
json={
"title": "Implement it",
"body": "Details",
"state": "open",
"merged": False,
"base": {"ref": "main"},
"head": {
"ref": "agent/work",
"sha": "def456",
"repo": {"owner": {"login": "org"}, "name": "repo"},
},
},
)
raise AssertionError(f"unexpected request: {request.method} {request.url}")
async with gitea_client(handler) as client:
comment_id = await client.create_comment("org", "repo", 4, "Working")
pull = await client.create_pull_request(
"org",
"repo",
title="Implement it",
body="Details",
head="agent/work",
base="main",
)
assert comment_id == 23
assert pull.number == 9
assert [(request.method, request.url.path) for request in requests] == [
("POST", "/api/v1/repos/org/repo/issues/4/comments"),
("POST", "/api/v1/repos/org/repo/pulls"),
("GET", "/api/v1/repos/org/repo/pulls/9"),
]
assert json.loads(requests[0].content) == {"body": "Working"}
assert json.loads(requests[1].content) == {
"title": "Implement it",
"body": "Details",
"head": "agent/work",
"base": "main",
}
async def test_maps_allowed_not_found_responses_without_retry() -> None:
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
return httpx.Response(404)
async with gitea_client(handler) as client:
updated = await client.update_comment("org", "repo", 99, "missing")
comments = await client.review_comments("org", "repo", 7, 3)
assert not updated
assert comments == []
assert paths == [
"/api/v1/repos/org/repo/issues/comments/99",
"/api/v1/repos/org/repo/pulls/7/reviews/3/comments",
]
@pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])])
async def test_pagination_stops_only_after_a_short_page(
first_page_size: int, expected_pages: list[int]
) -> None:
pages: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments"
assert request.url.params["limit"] == "50"
page = int(request.url.params["page"])
pages.append(page)
size = first_page_size if page == 1 else 1
offset = 0 if page == 1 else 50
return httpx.Response(
200,
json=[
{
"id": offset + index + 1,
"user": {"login": f"user-{offset + index + 1}"},
"body": None,
"created_at": None,
}
for index in range(size)
],
)
async with gitea_client(handler) as client:
comments = await client.issue_comments("org", "repo", 6)
expected_count = first_page_size + (1 if first_page_size == 50 else 0)
assert pages == expected_pages
assert len(comments) == expected_count
if comments:
assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="")
assert comments[-1].id == expected_count
@pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(status)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.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]
+77
View File
@@ -0,0 +1,77 @@
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response
from agentci.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
-143
View File
@@ -1,143 +0,0 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from agentci.adapters.gitea_models import IssueInfo, PullRequestInfo, RepositoryInfo
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.workflows.implement import ImplementWorkflow
from agentci.workflows.pull_request import PullRequestWorkflow
class SetupReached(RuntimeError):
pass
class FakeDevelopment:
description = "python"
def __init__(self, events: list[str]) -> None:
self.events = events
async def prepare(self, _workspace: Path) -> None:
self.events.append("prepare")
raise SetupReached
class FakeGit:
def __init__(self, events: list[str]) -> None:
self.events = events
async def clone(self, *_args) -> str:
self.events.append("clone")
return "base-sha"
async def create_branch(self, *_args) -> None:
self.events.append("create branch")
async def sync_branch(self, *_args) -> None:
self.events.append("sync branch")
class FakeStorage:
def __init__(self, workflow: Workflow | None = None) -> None:
self.workflow = workflow
async def implementation_workflows(self, *_args):
return []
async def create_workflow(self, _workflow) -> None:
return None
async def update_job(self, *_args, **_kwargs) -> None:
return None
async def workflow_for_pr(self, *_args):
return self.workflow
class FakeContext:
def __init__(self, pull: PullRequestInfo) -> None:
self.pull = pull
async def pull_request_context(self, *_args):
return self.pull, "context"
class FakeGitea:
async def repository(self, *_args) -> RepositoryInfo:
return RepositoryInfo("org", "repo", "org/repo", "main")
async def issue(self, *_args) -> IssueInfo:
return IssueInfo(1, "Issue", "Body", "open")
def job(kind: JobKind, *, pr_number: int | None = None) -> Job:
return Job(
id="job",
kind=kind,
target_key="org/repo:target",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=pr_number,
requester="alice",
message="",
comment_id=1,
)
def pull() -> PullRequestInfo:
return PullRequestInfo(2, "PR", "Body", "open", False, "main", "agent", "sha", "org", "repo")
async def test_initial_implementation_prepares_after_clone_and_branch(tmp_path) -> None:
events: list[str] = []
settings = SimpleNamespace(branch_prefix="agent", workspaces_dir=tmp_path)
deps = SimpleNamespace(
settings=settings,
storage=FakeStorage(),
gitea=FakeGitea(),
git=FakeGit(events),
development=FakeDevelopment(events),
)
with pytest.raises(SetupReached):
await ImplementWorkflow(deps).run(job(JobKind.IMPLEMENT)) # type: ignore[arg-type]
assert events == ["clone", "create branch", "prepare"]
@pytest.mark.parametrize("operation", ["iterate", "fix"])
async def test_pull_request_implementation_prepares_after_checkout(
tmp_path, operation: str
) -> None:
events: list[str] = []
existing = Workflow(
id="flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=tmp_path / "repo",
base_sha="base",
branch="agent",
primary_session_id="primary",
reviewer_session_id="reviewer",
status=WorkflowStatus.COMPLETED,
)
settings = SimpleNamespace(workspaces_dir=tmp_path)
deps = SimpleNamespace(
settings=settings,
storage=FakeStorage(existing),
context=FakeContext(pull()),
git=FakeGit(events),
development=FakeDevelopment(events),
)
workflow = PullRequestWorkflow(deps) # type: ignore[arg-type]
with pytest.raises(SetupReached):
await getattr(workflow, operation)(job(JobKind.FIX, pr_number=2))
expected_checkout = "sync branch" if operation == "iterate" else "clone"
assert events == [expected_checkout, "prepare"]

Some files were not shown because too many files have changed in this diff Show More