rewrite phase 1
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Agent CI repository instructions
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
|
||||||
|
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
|
||||||
|
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
|
||||||
|
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
|
||||||
|
recovery, deployment, or the security boundary.
|
||||||
|
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
|
||||||
|
the requested change merely for consistency.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
|
||||||
|
task claiming, and the `JobRun` event interface.
|
||||||
|
- `src/agentci/worker.py`: durable control/job queues, authorization, execution, recovery, and
|
||||||
|
comment reconciliation.
|
||||||
|
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
|
||||||
|
- `src/agentci/{gitea,git,opencode,codegraph,development}.py`: external-effect boundaries.
|
||||||
|
- `src/agentci/{app,runtime,config,webhook,health}.py`: application lifecycle, configuration, and
|
||||||
|
HTTP entry points.
|
||||||
|
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
|
||||||
|
contracts; keep these concerns outside Python orchestration.
|
||||||
|
- `src/agentci/migrations/`: ordered SQLite migrations.
|
||||||
|
- `tests/`: pytest suite, generally organized by module or behavior.
|
||||||
|
- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and
|
||||||
|
trusted runtime configuration.
|
||||||
|
|
||||||
|
## Setup and commands
|
||||||
|
|
||||||
|
Use Python 3.13 or newer and `uv`. Run commands from the repository root.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the narrowest relevant test while iterating:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run pytest tests/test_<area>.py
|
||||||
|
uv run pytest tests/test_<area>.py::test_<behavior>
|
||||||
|
uv run pytest -k '<expression>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the standard Python checks before completion:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run ruff check .
|
||||||
|
uv run pyright
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage is diagnostic and has no required threshold:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run pytest --cov=agentci --cov-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
For deployment-related changes, also run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
|
||||||
|
OpenCode configuration. It may require network access and takes longer than the normal checks.
|
||||||
|
|
||||||
|
## Engineering conventions
|
||||||
|
|
||||||
|
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
|
||||||
|
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
|
||||||
|
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
|
||||||
|
rather than mutating state in place.
|
||||||
|
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
|
||||||
|
events and task requests.
|
||||||
|
- Apply state transitions, event persistence, and resulting task creation atomically through
|
||||||
|
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
|
||||||
|
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
|
||||||
|
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
|
||||||
|
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
|
||||||
|
deterministic comment reconciliation.
|
||||||
|
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
|
||||||
|
failed and its sessions are aborted rather than replayed.
|
||||||
|
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
|
||||||
|
log credentials, secret contents, authorization headers, or private prompt data.
|
||||||
|
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
|
||||||
|
applied.
|
||||||
|
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
|
||||||
|
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
|
||||||
|
hand.
|
||||||
|
|
||||||
|
## Testing conventions
|
||||||
|
|
||||||
|
- Add regression tests for behavior changes, especially reducer transitions, persistence and
|
||||||
|
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
|
||||||
|
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
|
||||||
|
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
|
||||||
|
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
|
||||||
|
asynchronous.
|
||||||
|
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
|
||||||
|
error text rather than private implementation details.
|
||||||
|
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
|
||||||
|
or network access.
|
||||||
|
|
||||||
|
## Security and operational boundaries
|
||||||
|
|
||||||
|
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime
|
||||||
|
databases, cloned private repositories, or Docker volume contents. Do not send secrets or private
|
||||||
|
repository content to external search or research services.
|
||||||
|
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
|
||||||
|
secret-file loading.
|
||||||
|
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
|
||||||
|
or external skills, expose its server to the host, add privileged/capability settings, or add
|
||||||
|
writable host mounts without an explicit security review.
|
||||||
|
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
|
||||||
|
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
|
||||||
|
credentials to them.
|
||||||
|
- Git pushes performed by Agent CI must remain non-forcing.
|
||||||
|
- Do not run provider authentication, start/restart deployment services, modify production data, or
|
||||||
|
perform other live operations unless the user explicitly requests it.
|
||||||
|
- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`,
|
||||||
|
`.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`.
|
||||||
|
|
||||||
|
## Completion expectations
|
||||||
|
|
||||||
|
- Run focused tests first, then all applicable standard checks. If a check cannot run, report the
|
||||||
|
exact command and reason.
|
||||||
|
- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands,
|
||||||
|
configuration, deployment, recovery behavior, or security assumptions.
|
||||||
|
- Summarize behavior changes, validation performed, and any migration, compatibility, or security
|
||||||
|
implications in the final response or pull-request description.
|
||||||
@@ -4,6 +4,23 @@ Agent CI is a private Gitea webhook host that turns issue and pull-request comme
|
|||||||
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
|
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
|
||||||
private OpenCode server on the same Docker network as Gitea.
|
private OpenCode server on the same Docker network as Gitea.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The service has one durable execution path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
webhook -> repository -> reducer -> durable task -> worker -> workflow -> integration
|
||||||
|
```
|
||||||
|
|
||||||
|
`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions
|
||||||
|
atomically to SQLite and persists the resulting tasks. `worker.py` executes those tasks and passes
|
||||||
|
an explicit `JobRun` into the functions under `workflows/`. The top-level Gitea, Git, OpenCode,
|
||||||
|
development, and CodeGraph modules own external effects.
|
||||||
|
|
||||||
|
Jobs and workflows are immutable snapshots. Workflows return their final comment body directly;
|
||||||
|
progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table
|
||||||
|
is the authoritative state snapshot, while `job_events` provides durable idempotency and audit data.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Location | Command | Behavior |
|
| Location | Command | Behavior |
|
||||||
@@ -153,9 +170,12 @@ uv sync
|
|||||||
uv run ruff check .
|
uv run ruff check .
|
||||||
uv run pyright
|
uv run pyright
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
uv run pytest --cov=agentci --cov-branch
|
||||||
docker compose config
|
docker compose config
|
||||||
docker compose build
|
docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
|
The coverage command is an opt-in diagnostic report; the regular test run remains the default and
|
||||||
Python so orchestration modules remain small and readable.
|
coverage percentage is not used as a pass threshold.
|
||||||
|
|
||||||
|
Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow.
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
"""Gitea-triggered OpenCode workflow host."""
|
"""Gitea-triggered OpenCode workflow host."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
"""External-system adapters."""
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RepositoryInfo:
|
|
||||||
owner: str
|
|
||||||
name: str
|
|
||||||
full_name: str
|
|
||||||
default_branch: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IssueInfo:
|
|
||||||
number: int
|
|
||||||
title: str
|
|
||||||
body: str
|
|
||||||
state: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CommentInfo:
|
|
||||||
id: int
|
|
||||||
author: str
|
|
||||||
body: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PullRequestInfo:
|
|
||||||
number: int
|
|
||||||
title: str
|
|
||||||
body: str
|
|
||||||
state: str
|
|
||||||
merged: bool
|
|
||||||
base_branch: str
|
|
||||||
head_branch: str
|
|
||||||
head_sha: str
|
|
||||||
head_owner: str
|
|
||||||
head_repo: str
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_open(self) -> bool:
|
|
||||||
return self.state == "open" and not self.merged
|
|
||||||
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from agentci.adapters.database import Database, now
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
insert_event as _insert_event,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
insert_state as _insert_state,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
insert_tasks as _insert_tasks,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
insert_workflow as _insert_workflow,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
optional_state as _optional_state,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
replace_state as _replace_state,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
state as _state,
|
|
||||||
)
|
|
||||||
from agentci.adapters.state_persistence import (
|
|
||||||
state_from_row as _state_from_row,
|
|
||||||
)
|
|
||||||
from agentci.domain.events import CommandReceived, JobEvent, WorkflowCreated
|
|
||||||
from agentci.domain.models import CommandEvent
|
|
||||||
from agentci.domain.state_machine import JobState, next_state
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EvolveResult:
|
|
||||||
state: JobState
|
|
||||||
duplicate: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ListenerTask:
|
|
||||||
id: int
|
|
||||||
job_id: str
|
|
||||||
source_event_id: str
|
|
||||||
listener: str
|
|
||||||
queue: str
|
|
||||||
attempts: int
|
|
||||||
|
|
||||||
|
|
||||||
class JobStore(Database):
|
|
||||||
async def receive(
|
|
||||||
self, event_id: str, job_id: str, incoming: CommandEvent
|
|
||||||
) -> EvolveResult:
|
|
||||||
def operation(connection: sqlite3.Connection) -> EvolveResult:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
duplicate = connection.execute(
|
|
||||||
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
|
|
||||||
).fetchone()
|
|
||||||
if duplicate:
|
|
||||||
state = _state(connection, duplicate["job_id"])
|
|
||||||
connection.commit()
|
|
||||||
return EvolveResult(state, True)
|
|
||||||
sequence = connection.execute(
|
|
||||||
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
|
|
||||||
).fetchone()[0]
|
|
||||||
event = CommandReceived(
|
|
||||||
job_id=job_id,
|
|
||||||
delivery_id=incoming.delivery_id,
|
|
||||||
receive_sequence=sequence,
|
|
||||||
command_body=incoming.body,
|
|
||||||
target_key=incoming.target_key,
|
|
||||||
repo_owner=incoming.repo_owner,
|
|
||||||
repo_name=incoming.repo_name,
|
|
||||||
issue_number=incoming.issue_number,
|
|
||||||
pr_number=incoming.pr_number,
|
|
||||||
requester=incoming.requester,
|
|
||||||
comment_id=incoming.comment_id,
|
|
||||||
)
|
|
||||||
transition = next_state(None, event)
|
|
||||||
timestamp = now()
|
|
||||||
_insert_event(connection, event_id, event, timestamp)
|
|
||||||
_insert_state(connection, transition.state, timestamp)
|
|
||||||
_insert_tasks(connection, event_id, transition, timestamp)
|
|
||||||
connection.commit()
|
|
||||||
return EvolveResult(transition.state, False)
|
|
||||||
|
|
||||||
return await self._run(operation)
|
|
||||||
|
|
||||||
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
|
|
||||||
def operation(connection: sqlite3.Connection) -> EvolveResult:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
if connection.execute(
|
|
||||||
"SELECT 1 FROM job_events WHERE event_id=?", (event_id,)
|
|
||||||
).fetchone():
|
|
||||||
state = _state(connection, event.job_id)
|
|
||||||
connection.commit()
|
|
||||||
return EvolveResult(state, True)
|
|
||||||
current = _state(connection, event.job_id)
|
|
||||||
transition = next_state(current, event)
|
|
||||||
timestamp = now()
|
|
||||||
_insert_event(connection, event_id, event, timestamp)
|
|
||||||
if isinstance(event, WorkflowCreated):
|
|
||||||
_insert_workflow(connection, event, timestamp)
|
|
||||||
_replace_state(connection, transition.state, current, timestamp)
|
|
||||||
_insert_tasks(connection, event_id, transition, timestamp)
|
|
||||||
connection.commit()
|
|
||||||
return EvolveResult(transition.state, False)
|
|
||||||
|
|
||||||
return await self._run(operation)
|
|
||||||
|
|
||||||
async def get_job_state(self, job_id: str) -> JobState | None:
|
|
||||||
return await self._run(lambda connection: _optional_state(connection, job_id))
|
|
||||||
|
|
||||||
async def claim_task(self, queue: str) -> ListenerTask | None:
|
|
||||||
return await self._run(lambda connection: _claim_task(connection, queue))
|
|
||||||
|
|
||||||
async def complete_task(self, task_id: int) -> None:
|
|
||||||
await self._run(
|
|
||||||
lambda connection: connection.execute(
|
|
||||||
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
|
|
||||||
(now(), task_id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
|
|
||||||
delay = min(2 ** min(attempts, 8), 300)
|
|
||||||
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
|
|
||||||
await self._run(
|
|
||||||
lambda connection: connection.execute(
|
|
||||||
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
|
|
||||||
(available, error[:1000], task_id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def running_job_states(self) -> list[JobState]:
|
|
||||||
return await self._run(
|
|
||||||
lambda connection: [
|
|
||||||
_state_from_row(row)
|
|
||||||
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def recover_tasks(self) -> None:
|
|
||||||
def recover(connection: sqlite3.Connection) -> None:
|
|
||||||
with connection:
|
|
||||||
connection.execute(
|
|
||||||
"""UPDATE listener_tasks SET status='pending', started_at=NULL
|
|
||||||
WHERE status='running' AND queue='control'"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"""UPDATE listener_tasks SET status='pending', started_at=NULL
|
|
||||||
WHERE status='running' AND listener='execute' AND job_id IN
|
|
||||||
(SELECT id FROM jobs WHERE status='queued')"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"""UPDATE listener_tasks SET status='failed', finished_at=?,
|
|
||||||
error='Service restarted after execution began'
|
|
||||||
WHERE status='running' AND listener='execute' AND job_id IN
|
|
||||||
(SELECT id FROM jobs WHERE status<>'queued')""",
|
|
||||||
(now(),),
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._run(recover)
|
|
||||||
|
|
||||||
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
|
|
||||||
return await self._run(
|
|
||||||
lambda connection: {
|
|
||||||
value
|
|
||||||
for row in connection.execute(
|
|
||||||
"SELECT accepted_comment_id, started_comment_id FROM jobs "
|
|
||||||
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
|
|
||||||
(owner, repo, issue),
|
|
||||||
)
|
|
||||||
for value in row
|
|
||||||
if value is not None
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _claim_task(connection: sqlite3.Connection, queue: str) -> ListenerTask | None:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
fifo = ""
|
|
||||||
if queue == "jobs":
|
|
||||||
fifo = """AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
|
|
||||||
AND earlier.target_key = j.target_key
|
|
||||||
AND earlier.status IN ('received', 'queued', 'running'))"""
|
|
||||||
row = connection.execute(
|
|
||||||
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
|
|
||||||
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
|
|
||||||
ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""",
|
|
||||||
(queue, now()),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
connection.commit()
|
|
||||||
return None
|
|
||||||
changed = connection.execute(
|
|
||||||
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
|
|
||||||
"WHERE id=? AND status='pending'",
|
|
||||||
(now(), row["id"]),
|
|
||||||
)
|
|
||||||
if changed.rowcount != 1:
|
|
||||||
connection.rollback()
|
|
||||||
return None
|
|
||||||
connection.commit()
|
|
||||||
return ListenerTask(
|
|
||||||
row["id"], row["job_id"], row["source_event_id"], row["listener"],
|
|
||||||
row["queue"], row["attempts"] + 1,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -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."""
|
|
||||||
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""HTTP API."""
|
|
||||||
|
|
||||||
+7
-7
@@ -8,11 +8,11 @@ from contextlib import asynccontextmanager, suppress
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from agentci.api.health import router as health_router
|
|
||||||
from agentci.api.webhook import router as webhook_router
|
|
||||||
from agentci.config import Settings
|
from agentci.config import Settings
|
||||||
from agentci.container import build_container
|
from agentci.health import router as health_router
|
||||||
from agentci.logging import configure_logging
|
from agentci.logging import configure_logging
|
||||||
|
from agentci.runtime import build_runtime
|
||||||
|
from agentci.webhook import router as webhook_router
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -24,13 +24,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
configure_logging()
|
configure_logging()
|
||||||
log.info("service startup started", extra={"operation": "service.startup"})
|
log.info("service startup started", extra={"operation": "service.startup"})
|
||||||
try:
|
try:
|
||||||
container = await build_container(selected_settings)
|
runtime = await build_runtime(selected_settings)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("service startup failed", extra={"operation": "service.startup"})
|
log.exception("service startup failed", extra={"operation": "service.startup"})
|
||||||
raise
|
raise
|
||||||
app.state.container = container
|
app.state.runtime = runtime
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker")
|
worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
|
||||||
try:
|
try:
|
||||||
log.info("service startup completed", extra={"operation": "service.startup"})
|
log.info("service startup completed", extra={"operation": "service.startup"})
|
||||||
yield
|
yield
|
||||||
@@ -42,7 +42,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await worker_task
|
await worker_task
|
||||||
finally:
|
finally:
|
||||||
await container.close()
|
await runtime.close()
|
||||||
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
||||||
|
|
||||||
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CodeGraphClient:
|
class CodeGraph:
|
||||||
async def prepare(self, workspace: Path) -> None:
|
async def prepare(self, workspace: Path) -> None:
|
||||||
self._exclude_index(workspace)
|
self._exclude_index(workspace)
|
||||||
index = workspace / ".codegraph" / "codegraph.db"
|
index = workspace / ".codegraph" / "codegraph.db"
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Domain types and policies."""
|
|
||||||
|
|
||||||
@@ -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}"
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Durable job and workflow engine."""
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agentci.engine.events import (
|
||||||
|
JobProgress,
|
||||||
|
RuntimeSessionLinked,
|
||||||
|
WorkflowCreated,
|
||||||
|
WorkflowLinked,
|
||||||
|
)
|
||||||
|
from agentci.engine.model import Workflow
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
type ReportEvent = JobProgress | RuntimeSessionLinked | WorkflowCreated | WorkflowLinked
|
||||||
|
|
||||||
|
|
||||||
|
class JobRun:
|
||||||
|
def __init__(self, repository: Repository, job_id: str, task_id: int) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.job_id = job_id
|
||||||
|
self.task_id = task_id
|
||||||
|
self._sequence = 0
|
||||||
|
|
||||||
|
async def stage(self, stage: str) -> None:
|
||||||
|
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
|
||||||
|
|
||||||
|
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||||
|
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
|
||||||
|
|
||||||
|
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||||
|
await self._emit(
|
||||||
|
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def link_session(self, session_id: str) -> None:
|
||||||
|
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
|
||||||
|
|
||||||
|
async def _emit(self, event: ReportEvent) -> None:
|
||||||
|
self._sequence += 1
|
||||||
|
event_id = f"task:{self.task_id}:report:{self._sequence}"
|
||||||
|
await self.repository.apply(event_id, event)
|
||||||
@@ -13,7 +13,7 @@ class GitError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GitClient:
|
class Git:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -31,9 +31,6 @@ class GitClient:
|
|||||||
self.commit_name = commit_name
|
self.commit_name = commit_name
|
||||||
self.commit_email = commit_email
|
self.commit_email = commit_email
|
||||||
|
|
||||||
def clone_url(self, owner: str, repo: str) -> str:
|
|
||||||
return f"{self.gitea_url}/{owner}/{repo}.git"
|
|
||||||
|
|
||||||
async def clone(
|
async def clone(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
@@ -47,7 +44,7 @@ class GitClient:
|
|||||||
"--branch",
|
"--branch",
|
||||||
branch,
|
branch,
|
||||||
"--single-branch",
|
"--single-branch",
|
||||||
self.clone_url(owner, repo),
|
f"{self.gitea_url}/{owner}/{repo}.git",
|
||||||
str(destination),
|
str(destination),
|
||||||
cwd=destination.parent,
|
cwd=destination.parent,
|
||||||
authenticated=True,
|
authenticated=True,
|
||||||
@@ -85,6 +82,7 @@ class GitClient:
|
|||||||
"-m",
|
"-m",
|
||||||
message,
|
message,
|
||||||
cwd=workspace,
|
cwd=workspace,
|
||||||
|
command_name="commit",
|
||||||
)
|
)
|
||||||
return await self.current_sha(workspace)
|
return await self.current_sha(workspace)
|
||||||
|
|
||||||
@@ -101,8 +99,10 @@ class GitClient:
|
|||||||
*args: str,
|
*args: str,
|
||||||
cwd: Path,
|
cwd: Path,
|
||||||
authenticated: bool = False,
|
authenticated: bool = False,
|
||||||
|
command_name: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
operation = f"git.{args[0]}"
|
command_name = command_name or args[0]
|
||||||
|
operation = f"git.{command_name}"
|
||||||
started = monotonic()
|
started = monotonic()
|
||||||
log.info("git step started", extra={"operation": operation})
|
log.info("git step started", extra={"operation": operation})
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
@@ -130,14 +130,14 @@ class GitClient:
|
|||||||
"git step could not start",
|
"git step could not start",
|
||||||
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
||||||
)
|
)
|
||||||
raise GitError(f"Could not run git {args[0]}: {exc}") from exc
|
raise GitError(f"Could not run git {command_name}: {exc}") from exc
|
||||||
if process.returncode:
|
if process.returncode:
|
||||||
detail = stderr.decode(errors="replace").strip()
|
detail = stderr.decode(errors="replace").strip()
|
||||||
log.error(
|
log.error(
|
||||||
"git step failed",
|
"git step failed",
|
||||||
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
||||||
)
|
)
|
||||||
raise GitError(f"git {args[0]} failed: {detail[-1000:]}")
|
raise GitError(f"git {command_name} failed: {detail[-1000:]}")
|
||||||
log.info(
|
log.info(
|
||||||
"git step completed",
|
"git step completed",
|
||||||
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
||||||
@@ -2,33 +2,69 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from agentci.adapters.gitea_models import (
|
|
||||||
CommentInfo,
|
|
||||||
IssueInfo,
|
|
||||||
PullRequestInfo,
|
|
||||||
RepositoryInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IssueInfo:
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommentInfo:
|
||||||
|
id: int
|
||||||
|
author: str
|
||||||
|
body: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PullRequestInfo:
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
state: str
|
||||||
|
merged: bool
|
||||||
|
base_branch: str
|
||||||
|
head_branch: str
|
||||||
|
head_sha: str
|
||||||
|
head_owner: str
|
||||||
|
head_repo: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self.state == "open" and not self.merged
|
||||||
|
|
||||||
|
|
||||||
class GiteaError(RuntimeError):
|
class GiteaError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class Gitea:
|
||||||
def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
retries: int = 3,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.retries = retries
|
self.retries = retries
|
||||||
self.client = httpx.AsyncClient(
|
self.client = httpx.AsyncClient(
|
||||||
base_url=f"{self.base_url}/api/v1",
|
base_url=f"{self.base_url}/api/v1",
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
transport=transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
@@ -41,14 +77,9 @@ class GiteaClient:
|
|||||||
permission = str(response.json().get("permission", "")).lower()
|
permission = str(response.json().get("permission", "")).lower()
|
||||||
return permission in {"write", "admin", "owner"}
|
return permission in {"write", "admin", "owner"}
|
||||||
|
|
||||||
async def repository(self, owner: str, repo: str) -> RepositoryInfo:
|
async def default_branch(self, owner: str, repo: str) -> str:
|
||||||
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
|
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
|
||||||
return RepositoryInfo(
|
return str(data["default_branch"])
|
||||||
owner=owner,
|
|
||||||
name=repo,
|
|
||||||
full_name=data.get("full_name", f"{owner}/{repo}"),
|
|
||||||
default_branch=data["default_branch"],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
|
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
|
||||||
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
|
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
|
||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Response, status
|
from fastapi import APIRouter, Request, Response, status
|
||||||
|
|
||||||
|
from agentci.runtime import Runtime
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +14,8 @@ async def live() -> dict[str, str]:
|
|||||||
|
|
||||||
@router.get("/health/ready")
|
@router.get("/health/ready")
|
||||||
async def ready(request: Request, response: Response) -> dict[str, str]:
|
async def ready(request: Request, response: Response) -> dict[str, str]:
|
||||||
if not await request.app.state.container.opencode.ready():
|
runtime: Runtime = request.app.state.runtime
|
||||||
|
if not await runtime.opencode.ready():
|
||||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
return {"status": "not-ready", "reason": "opencode provider is not connected"}
|
return {"status": "not-ready", "reason": "opencode provider is not connected"}
|
||||||
return {"status": "ready"}
|
return {"status": "ready"}
|
||||||
@@ -37,9 +37,14 @@ class JsonFormatter(logging.Formatter):
|
|||||||
try:
|
try:
|
||||||
return json.dumps(payload, ensure_ascii=False)
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
payload["message"] = "Log record could not be serialized"
|
fallback = {
|
||||||
payload["exception"] = traceback.format_exc()
|
"timestamp": payload["timestamp"],
|
||||||
return json.dumps(payload, ensure_ascii=False)
|
"level": payload["level"],
|
||||||
|
"logger": payload["logger"],
|
||||||
|
"message": "Log record could not be serialized",
|
||||||
|
"exception": traceback.format_exc(),
|
||||||
|
}
|
||||||
|
return json.dumps(fallback, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
@@ -9,16 +11,7 @@ from typing import Any, TypeVar
|
|||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from agentci.adapters.codegraph import CodeGraphClient
|
from agentci.codegraph import CodeGraph
|
||||||
from agentci.adapters.opencode_support import (
|
|
||||||
api_contract_ready,
|
|
||||||
directory_headers,
|
|
||||||
elapsed_ms,
|
|
||||||
error_message,
|
|
||||||
load_schema,
|
|
||||||
model_parts,
|
|
||||||
models_ready,
|
|
||||||
)
|
|
||||||
|
|
||||||
T = TypeVar("T", bound=BaseModel)
|
T = TypeVar("T", bound=BaseModel)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -28,7 +21,7 @@ class OpenCodeError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OpenCodeClient:
|
class OpenCode:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -39,7 +32,7 @@ class OpenCodeClient:
|
|||||||
health_directory: Path,
|
health_directory: Path,
|
||||||
required_models: tuple[tuple[str, str | None], ...],
|
required_models: tuple[tuple[str, str | None], ...],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
codegraph: CodeGraphClient | None = None,
|
codegraph: CodeGraph | None = None,
|
||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.schemas_dir = schemas_dir
|
self.schemas_dir = schemas_dir
|
||||||
@@ -48,8 +41,9 @@ class OpenCodeClient:
|
|||||||
(*model_parts(model), variant) for model, variant in required_models
|
(*model_parts(model), variant) for model, variant in required_models
|
||||||
}
|
}
|
||||||
self._contract_valid: bool | None = None
|
self._contract_valid: bool | None = None
|
||||||
|
self._readiness_task: asyncio.Task[bool] | None = None
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
self.codegraph = codegraph or CodeGraphClient()
|
self.codegraph = codegraph or CodeGraph()
|
||||||
self._active_sessions: dict[str, Path] = {}
|
self._active_sessions: dict[str, Path] = {}
|
||||||
self.client = httpx.AsyncClient(
|
self.client = httpx.AsyncClient(
|
||||||
base_url=base_url.rstrip("/"),
|
base_url=base_url.rstrip("/"),
|
||||||
@@ -59,11 +53,27 @@ class OpenCodeClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
if self._readiness_task is not None and not self._readiness_task.done():
|
||||||
|
self._readiness_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await self._readiness_task
|
||||||
|
self._readiness_task = None
|
||||||
for session_id, workspace in tuple(self._active_sessions.items()):
|
for session_id, workspace in tuple(self._active_sessions.items()):
|
||||||
await self.abort(session_id, workspace, best_effort=True)
|
await self.abort(session_id, workspace, best_effort=True)
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|
||||||
async def ready(self) -> bool:
|
async def ready(self) -> bool:
|
||||||
|
task = self._readiness_task
|
||||||
|
if task is None:
|
||||||
|
task = asyncio.create_task(self._check_ready())
|
||||||
|
self._readiness_task = task
|
||||||
|
try:
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
finally:
|
||||||
|
if task.done() and self._readiness_task is task:
|
||||||
|
self._readiness_task = None
|
||||||
|
|
||||||
|
async def _check_ready(self) -> bool:
|
||||||
try:
|
try:
|
||||||
health = await self.client.get("/global/health", timeout=10)
|
health = await self.client.get("/global/health", timeout=10)
|
||||||
health.raise_for_status()
|
health.raise_for_status()
|
||||||
@@ -85,28 +95,6 @@ class OpenCodeClient:
|
|||||||
except (httpx.HTTPError, TypeError, ValueError):
|
except (httpx.HTTPError, TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
workspace: Path,
|
|
||||||
prompt: str,
|
|
||||||
model: str,
|
|
||||||
variant: str | None,
|
|
||||||
schema_name: str,
|
|
||||||
result_type: type[T],
|
|
||||||
) -> tuple[str, T]:
|
|
||||||
session_id = await self.create_session(workspace, schema_name)
|
|
||||||
result = await self.resume(
|
|
||||||
session_id=session_id,
|
|
||||||
workspace=workspace,
|
|
||||||
prompt=prompt,
|
|
||||||
model=model,
|
|
||||||
variant=variant,
|
|
||||||
schema_name=schema_name,
|
|
||||||
result_type=result_type,
|
|
||||||
)
|
|
||||||
return session_id, result
|
|
||||||
|
|
||||||
async def create_session(self, workspace: Path, title: str) -> str:
|
async def create_session(self, workspace: Path, title: str) -> str:
|
||||||
response = await self._request(
|
response = await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -143,7 +131,10 @@ class OpenCodeClient:
|
|||||||
result_type=result_type,
|
result_type=result_type,
|
||||||
)
|
)
|
||||||
except (asyncio.CancelledError, OpenCodeError):
|
except (asyncio.CancelledError, OpenCodeError):
|
||||||
await asyncio.shield(self.abort(session_id, workspace))
|
try:
|
||||||
|
await asyncio.shield(self.abort(session_id, workspace))
|
||||||
|
except OpenCodeError as exc:
|
||||||
|
log.warning("OpenCode failed session could not be aborted", exc_info=exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
self._active_sessions.pop(session_id, None)
|
self._active_sessions.pop(session_id, None)
|
||||||
@@ -186,7 +177,6 @@ class OpenCodeClient:
|
|||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
await self.abort(session_id, workspace)
|
|
||||||
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
|
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
|
||||||
raise OpenCodeError(message) from exc
|
raise OpenCodeError(message) from exc
|
||||||
info = response.get("info")
|
info = response.get("info")
|
||||||
@@ -213,7 +203,10 @@ class OpenCodeClient:
|
|||||||
raise OpenCodeError("OpenCode did not return a valid result")
|
raise OpenCodeError("OpenCode did not return a valid result")
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self, method: str, path: str, *,
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
json: dict[str, Any] | None = None,
|
json: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -248,3 +241,91 @@ class OpenCodeClient:
|
|||||||
log.warning("OpenCode session could not be aborted", exc_info=exc)
|
log.warning("OpenCode session could not be aborted", exc_info=exc)
|
||||||
return
|
return
|
||||||
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
|
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def model_parts(model: str) -> tuple[str, str]:
|
||||||
|
provider, separator, model_id = model.partition("/")
|
||||||
|
if not separator or not provider or not model_id:
|
||||||
|
raise ValueError(f"OpenCode model must use provider/model format: {model}")
|
||||||
|
return provider, model_id
|
||||||
|
|
||||||
|
|
||||||
|
def error_message(error: object) -> str | None:
|
||||||
|
if not error:
|
||||||
|
return None
|
||||||
|
if isinstance(error, dict):
|
||||||
|
return str(error.get("name") or error.get("message") or error)
|
||||||
|
return str(error)
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_ms(started: float) -> int:
|
||||||
|
return round((monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def directory_headers(workspace: Path) -> dict[str, str]:
|
||||||
|
return {"X-Opencode-Directory": str(workspace.resolve())}
|
||||||
|
|
||||||
|
|
||||||
|
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads((schemas_dir / name).read_text())
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"Result schema {name} is not a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def api_contract_ready(document: object) -> bool:
|
||||||
|
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
|
||||||
|
return False
|
||||||
|
paths = document["paths"]
|
||||||
|
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
|
||||||
|
for path, method in fixed.items():
|
||||||
|
operations = paths.get(path)
|
||||||
|
if not isinstance(operations, dict) or method not in operations:
|
||||||
|
return False
|
||||||
|
session_paths = [
|
||||||
|
path
|
||||||
|
for path, operations in paths.items()
|
||||||
|
if isinstance(path, str) and isinstance(operations, dict) and path.startswith("/session/{")
|
||||||
|
]
|
||||||
|
has_message = any(path.endswith("/message") and "post" in paths[path] for path in session_paths)
|
||||||
|
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
|
||||||
|
return has_message and has_abort
|
||||||
|
|
||||||
|
|
||||||
|
def models_ready(payload: object, requirements: set[tuple[str, str, str | None]]) -> bool:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
connected_value = payload.get("connected")
|
||||||
|
provider_values = payload.get("all")
|
||||||
|
if not isinstance(connected_value, list) or not all(
|
||||||
|
isinstance(item, str) for item in connected_value
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if not isinstance(provider_values, list):
|
||||||
|
return False
|
||||||
|
connected = set(connected_value)
|
||||||
|
providers: dict[str, dict[str, Any]] = {}
|
||||||
|
for item in provider_values:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
provider_id = item.get("id")
|
||||||
|
if isinstance(provider_id, str) and isinstance(item.get("models"), dict):
|
||||||
|
providers[provider_id] = item
|
||||||
|
for provider_id, model_id, variant in requirements:
|
||||||
|
provider = providers.get(provider_id)
|
||||||
|
if provider_id not in connected or not isinstance(provider, dict):
|
||||||
|
return False
|
||||||
|
model: Any = provider["models"].get(model_id)
|
||||||
|
if not isinstance(model, dict) or model.get("status") == "deprecated":
|
||||||
|
return False
|
||||||
|
capabilities = model.get("capabilities")
|
||||||
|
if not isinstance(capabilities, dict) or capabilities.get("toolcall") is not True:
|
||||||
|
return False
|
||||||
|
if variant:
|
||||||
|
variants = model.get("variants")
|
||||||
|
if not isinstance(variants, dict) or variant not in variants:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextvars import ContextVar, Token
|
|
||||||
|
|
||||||
from agentci.domain.events import (
|
|
||||||
JobProgress,
|
|
||||||
RuntimeSessionLinked,
|
|
||||||
WorkflowCreated,
|
|
||||||
WorkflowLinked,
|
|
||||||
)
|
|
||||||
from agentci.domain.models import Workflow
|
|
||||||
from agentci.state_machine import StateMachine
|
|
||||||
|
|
||||||
|
|
||||||
class NullReporter:
|
|
||||||
final_body: str | None = None
|
|
||||||
|
|
||||||
async def progress(self, _stage: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def create_workflow(self, _workflow: Workflow, _stage: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def link_workflow(self, _workflow_id: str, _stage: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def link_runtime_session(self, _session_id: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def finish(self, body: str) -> None:
|
|
||||||
self.final_body = body
|
|
||||||
|
|
||||||
|
|
||||||
_current: ContextVar[JobReporter | NullReporter | None] = ContextVar(
|
|
||||||
"job_reporter", default=None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JobReporter:
|
|
||||||
def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None:
|
|
||||||
self.host = host
|
|
||||||
self.job_id = job_id
|
|
||||||
self.task_id = task_id
|
|
||||||
self.sequence = 0
|
|
||||||
self.final_body: str | None = None
|
|
||||||
|
|
||||||
async def progress(self, stage: str) -> None:
|
|
||||||
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
|
|
||||||
|
|
||||||
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
|
||||||
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
|
|
||||||
|
|
||||||
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
|
||||||
await self._emit(
|
|
||||||
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def link_runtime_session(self, session_id: str) -> None:
|
|
||||||
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
|
|
||||||
|
|
||||||
def finish(self, body: str) -> None:
|
|
||||||
self.final_body = body
|
|
||||||
|
|
||||||
async def _emit(self, event) -> None:
|
|
||||||
self.sequence += 1
|
|
||||||
await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event)
|
|
||||||
|
|
||||||
|
|
||||||
def bind_reporter(
|
|
||||||
reporter: JobReporter,
|
|
||||||
) -> Token[JobReporter | NullReporter | None]:
|
|
||||||
return _current.set(reporter)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None:
|
|
||||||
_current.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def reporter() -> JobReporter | NullReporter:
|
|
||||||
return _current.get() or NullReporter()
|
|
||||||
@@ -4,48 +4,45 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.adapters.development import DevelopmentEnvironment
|
|
||||||
from agentci.adapters.git import GitClient
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
|
||||||
from agentci.adapters.opencode import OpenCodeClient
|
|
||||||
from agentci.adapters.storage import Storage
|
|
||||||
from agentci.config import Settings
|
from agentci.config import Settings
|
||||||
|
from agentci.development import DevelopmentEnvironment
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.git import Git
|
||||||
|
from agentci.gitea import Gitea
|
||||||
|
from agentci.opencode import OpenCode
|
||||||
from agentci.prompts import PromptLibrary
|
from agentci.prompts import PromptLibrary
|
||||||
from agentci.state_machine import StateMachine
|
|
||||||
from agentci.worker import Worker
|
from agentci.worker import Worker
|
||||||
from agentci.workflows.common import Dependencies
|
from agentci.workflows.services import WorkflowServices
|
||||||
from agentci.workflows.context import ContextBuilder
|
|
||||||
from agentci.workflows.dispatcher import Dispatcher
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Container:
|
class Runtime:
|
||||||
settings: Settings
|
settings: Settings
|
||||||
storage: Storage
|
repository: Repository
|
||||||
gitea: GiteaClient
|
gitea: Gitea
|
||||||
git: GitClient
|
git: Git
|
||||||
opencode: OpenCodeClient
|
opencode: OpenCode
|
||||||
state_machine: StateMachine
|
|
||||||
worker: Worker
|
worker: Worker
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
log.info("container shutdown started", extra={"operation": "container.close"})
|
log.info("runtime shutdown started", extra={"operation": "runtime.close"})
|
||||||
await self.opencode.close()
|
try:
|
||||||
await self.gitea.close()
|
await self.opencode.close()
|
||||||
log.info("container shutdown completed", extra={"operation": "container.close"})
|
finally:
|
||||||
|
await self.gitea.close()
|
||||||
|
log.info("runtime shutdown completed", extra={"operation": "runtime.close"})
|
||||||
|
|
||||||
|
|
||||||
async def build_container(settings: Settings) -> Container:
|
async def build_runtime(settings: Settings) -> Runtime:
|
||||||
log.info("container initialization started", extra={"operation": "container.build"})
|
log.info("runtime initialization started", extra={"operation": "runtime.build"})
|
||||||
package_dir = Path(__file__).parent
|
package_dir = Path(__file__).parent
|
||||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
||||||
storage = Storage(settings.database_path, package_dir / "migrations")
|
repository = Repository(settings.database_path, package_dir / "migrations")
|
||||||
await storage.initialize()
|
await repository.initialize()
|
||||||
state_machine = StateMachine(storage)
|
gitea = Gitea(settings.gitea_url, settings.gitea_token)
|
||||||
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
|
git = Git(
|
||||||
git = GitClient(
|
|
||||||
gitea_url=settings.gitea_url,
|
gitea_url=settings.gitea_url,
|
||||||
username=settings.bot_username,
|
username=settings.bot_username,
|
||||||
token=settings.gitea_token,
|
token=settings.gitea_token,
|
||||||
@@ -53,7 +50,7 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
commit_name=settings.bot_name,
|
commit_name=settings.bot_name,
|
||||||
commit_email=settings.bot_email,
|
commit_email=settings.bot_email,
|
||||||
)
|
)
|
||||||
opencode = OpenCodeClient(
|
opencode = OpenCode(
|
||||||
base_url=settings.opencode_url,
|
base_url=settings.opencode_url,
|
||||||
username=settings.opencode_server_username,
|
username=settings.opencode_server_username,
|
||||||
password=settings.opencode_server_password,
|
password=settings.opencode_server_password,
|
||||||
@@ -67,8 +64,6 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
),
|
),
|
||||||
timeout_seconds=settings.turn_timeout_seconds,
|
timeout_seconds=settings.turn_timeout_seconds,
|
||||||
)
|
)
|
||||||
prompts = PromptLibrary()
|
|
||||||
context = ContextBuilder(gitea, storage)
|
|
||||||
development = DevelopmentEnvironment(
|
development = DevelopmentEnvironment(
|
||||||
scripts=settings.install_scripts,
|
scripts=settings.install_scripts,
|
||||||
scripts_dir=settings.install_scripts_dir,
|
scripts_dir=settings.install_scripts_dir,
|
||||||
@@ -77,28 +72,25 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
python_version=settings.python_version,
|
python_version=settings.python_version,
|
||||||
dotnet_channel=settings.dotnet_channel,
|
dotnet_channel=settings.dotnet_channel,
|
||||||
)
|
)
|
||||||
dependencies = Dependencies(
|
services = WorkflowServices(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
storage=storage,
|
repository=repository,
|
||||||
gitea=gitea,
|
gitea=gitea,
|
||||||
git=git,
|
git=git,
|
||||||
opencode=opencode,
|
opencode=opencode,
|
||||||
prompts=prompts,
|
prompts=PromptLibrary(),
|
||||||
context=context,
|
|
||||||
development=development,
|
development=development,
|
||||||
)
|
)
|
||||||
dispatcher = Dispatcher(dependencies)
|
|
||||||
worker = Worker(
|
worker = Worker(
|
||||||
storage=storage,
|
repository=repository,
|
||||||
state_machine=state_machine,
|
|
||||||
gitea=gitea,
|
gitea=gitea,
|
||||||
opencode=opencode,
|
opencode=opencode,
|
||||||
dispatcher=dispatcher,
|
services=services,
|
||||||
poll_seconds=settings.worker_poll_seconds,
|
poll_seconds=settings.worker_poll_seconds,
|
||||||
max_concurrent_jobs=settings.max_concurrent_jobs,
|
max_concurrent_jobs=settings.max_concurrent_jobs,
|
||||||
workspaces_dir=settings.workspaces_dir,
|
workspaces_dir=settings.workspaces_dir,
|
||||||
bot_username=settings.bot_username,
|
bot_username=settings.bot_username,
|
||||||
)
|
)
|
||||||
container = Container(settings, storage, gitea, git, opencode, state_machine, worker)
|
runtime = Runtime(settings, repository, gitea, git, opencode, worker)
|
||||||
log.info("container initialization completed", extra={"operation": "container.build"})
|
log.info("runtime initialization completed", extra={"operation": "runtime.build"})
|
||||||
return container
|
return runtime
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from uuid import UUID, uuid5
|
|
||||||
|
|
||||||
from agentci.adapters.job_store import EvolveResult, JobStore
|
|
||||||
from agentci.domain.events import JobEvent
|
|
||||||
from agentci.domain.models import CommandEvent
|
|
||||||
from agentci.domain.state_machine import JobState
|
|
||||||
|
|
||||||
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ReceiveResult:
|
|
||||||
state: JobState
|
|
||||||
duplicate: bool
|
|
||||||
|
|
||||||
|
|
||||||
class StateMachine:
|
|
||||||
def __init__(self, store: JobStore) -> None:
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
async def receive(self, incoming: CommandEvent) -> ReceiveResult:
|
|
||||||
job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id))
|
|
||||||
result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming)
|
|
||||||
return ReceiveResult(result.state, result.duplicate)
|
|
||||||
|
|
||||||
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
|
|
||||||
return await self.store.evolve(event_id, event)
|
|
||||||
|
|
||||||
async def get(self, job_id: str) -> JobState | None:
|
|
||||||
return await self.store.get_job_state(job_id)
|
|
||||||
@@ -8,7 +8,8 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
from agentci.domain.models import CommandEvent
|
from agentci.engine.model import IncomingCommand
|
||||||
|
from agentci.runtime import Runtime
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -21,10 +22,10 @@ SUPPORTED_EVENTS = {
|
|||||||
|
|
||||||
@router.post("/webhooks/gitea")
|
@router.post("/webhooks/gitea")
|
||||||
async def webhook(request: Request) -> Response:
|
async def webhook(request: Request) -> Response:
|
||||||
container = request.app.state.container
|
runtime: Runtime = request.app.state.runtime
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
signature = request.headers.get("X-Gitea-Signature", "")
|
signature = request.headers.get("X-Gitea-Signature", "")
|
||||||
if not valid_signature(container.settings.webhook_secret, body, signature):
|
if not valid_signature(runtime.settings.webhook_secret, body, signature):
|
||||||
log.warning(
|
log.warning(
|
||||||
"webhook signature rejected",
|
"webhook signature rejected",
|
||||||
extra={"operation": "webhook.verify", "path": request.url.path},
|
extra={"operation": "webhook.verify", "path": request.url.path},
|
||||||
@@ -41,21 +42,23 @@ async def webhook(request: Request) -> Response:
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
try:
|
try:
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Webhook payload must be a JSON object")
|
||||||
event = _event_from_payload(
|
event = _event_from_payload(
|
||||||
request.headers.get("X-Gitea-Delivery", ""),
|
request.headers.get("X-Gitea-Delivery", ""),
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
log.warning(
|
log.warning(
|
||||||
"webhook payload rejected",
|
"webhook payload rejected",
|
||||||
extra={"operation": "webhook.parse", "stage": event_name},
|
extra={"operation": "webhook.parse", "stage": event_name},
|
||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
|
||||||
if event is None or event.requester.casefold() == container.settings.bot_username.casefold():
|
if event is None or event.requester.casefold() == runtime.settings.bot_username.casefold():
|
||||||
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
|
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
return await _handle_command(container, event)
|
return await _handle_command(runtime, event)
|
||||||
|
|
||||||
|
|
||||||
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
||||||
@@ -63,7 +66,7 @@ def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
|||||||
return bool(signature) and hmac.compare_digest(expected, signature)
|
return bool(signature) and hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_command(container: Any, event: CommandEvent) -> Response:
|
async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
|
||||||
extra = {"operation": "command.handle", "target": event.target_key}
|
extra = {"operation": "command.handle", "target": event.target_key}
|
||||||
if not event.body.strip().startswith("/agent"):
|
if not event.body.strip().startswith("/agent"):
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -71,25 +74,25 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
|
|||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
|
||||||
log.info("agent command received", extra=extra)
|
log.info("agent command received", extra=extra)
|
||||||
try:
|
try:
|
||||||
result = await container.state_machine.receive(event)
|
result = await runtime.repository.accept(event)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("could not persist command", extra=extra)
|
log.exception("could not persist command", extra=extra)
|
||||||
raise
|
raise
|
||||||
if result.duplicate:
|
if result.duplicate:
|
||||||
log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id})
|
log.info("duplicate command ignored", extra={**extra, "job_id": result.job.id})
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
log.info(
|
log.info(
|
||||||
"agent command persisted",
|
"agent command persisted",
|
||||||
extra={
|
extra={
|
||||||
**extra,
|
**extra,
|
||||||
"job_id": result.state.id,
|
"job_id": result.job.id,
|
||||||
"receive_sequence": result.state.receive_sequence,
|
"receive_sequence": result.job.receive_sequence,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
return Response(status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
|
|
||||||
def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEvent | None:
|
def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> IncomingCommand | None:
|
||||||
if payload.get("action") != "created":
|
if payload.get("action") != "created":
|
||||||
return None
|
return None
|
||||||
comment = payload["comment"]
|
comment = payload["comment"]
|
||||||
@@ -103,7 +106,7 @@ def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEve
|
|||||||
if target is None:
|
if target is None:
|
||||||
raise ValueError("Comment payload has no issue or pull request")
|
raise ValueError("Comment payload has no issue or pull request")
|
||||||
number = int(target["number"])
|
number = int(target["number"])
|
||||||
return CommandEvent(
|
return IncomingCommand(
|
||||||
delivery_id=delivery_id,
|
delivery_id=delivery_id,
|
||||||
comment_id=int(comment["id"]),
|
comment_id=int(comment["id"]),
|
||||||
repo_owner=owner_name,
|
repo_owner=owner_name,
|
||||||
+83
-82
@@ -5,11 +5,7 @@ import logging
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.engine.events import (
|
||||||
from agentci.adapters.job_store import ListenerTask
|
|
||||||
from agentci.adapters.opencode import OpenCodeClient
|
|
||||||
from agentci.adapters.storage import Storage
|
|
||||||
from agentci.domain.events import (
|
|
||||||
CommentLinked,
|
CommentLinked,
|
||||||
JobCompleted,
|
JobCompleted,
|
||||||
JobFailed,
|
JobFailed,
|
||||||
@@ -18,15 +14,18 @@ from agentci.domain.events import (
|
|||||||
PermissionGranted,
|
PermissionGranted,
|
||||||
ServiceRestarted,
|
ServiceRestarted,
|
||||||
)
|
)
|
||||||
from agentci.domain.events import (
|
from agentci.engine.events import (
|
||||||
JobRejected as RejectedEvent,
|
JobRejected as RejectedEvent,
|
||||||
)
|
)
|
||||||
from agentci.domain.models import JobStatus
|
from agentci.engine.model import Job, JobStatus, QueueName, Task, TaskKind
|
||||||
from agentci.domain.state_machine import JobState, render_job_comment
|
from agentci.engine.reducer import render_job_comment
|
||||||
from agentci.reporting import JobReporter, bind_reporter, reset_reporter
|
from agentci.engine.repository import Repository
|
||||||
from agentci.state_machine import StateMachine
|
from agentci.engine.run import JobRun
|
||||||
from agentci.workflows.common import JobRejected
|
from agentci.gitea import Gitea
|
||||||
from agentci.workflows.dispatcher import Dispatcher
|
from agentci.opencode import OpenCode
|
||||||
|
from agentci.workflows.dispatch import dispatch
|
||||||
|
from agentci.workflows.render import JobRejected
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,21 +34,19 @@ class Worker:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
storage: Storage,
|
repository: Repository,
|
||||||
state_machine: StateMachine,
|
gitea: Gitea,
|
||||||
gitea: GiteaClient,
|
opencode: OpenCode,
|
||||||
opencode: OpenCodeClient,
|
services: WorkflowServices,
|
||||||
dispatcher: Dispatcher,
|
|
||||||
poll_seconds: float,
|
poll_seconds: float,
|
||||||
max_concurrent_jobs: int,
|
max_concurrent_jobs: int,
|
||||||
workspaces_dir: Path,
|
workspaces_dir: Path,
|
||||||
bot_username: str,
|
bot_username: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.storage = storage
|
self.repository = repository
|
||||||
self.host = state_machine
|
|
||||||
self.gitea = gitea
|
self.gitea = gitea
|
||||||
self.opencode = opencode
|
self.opencode = opencode
|
||||||
self.dispatcher = dispatcher
|
self.services = services
|
||||||
self.poll_seconds = poll_seconds
|
self.poll_seconds = poll_seconds
|
||||||
self.max_concurrent_jobs = max_concurrent_jobs
|
self.max_concurrent_jobs = max_concurrent_jobs
|
||||||
self.workspaces_dir = workspaces_dir
|
self.workspaces_dir = workspaces_dir
|
||||||
@@ -58,16 +55,16 @@ class Worker:
|
|||||||
async def run(self, stop: asyncio.Event) -> None:
|
async def run(self, stop: asyncio.Event) -> None:
|
||||||
await self._recover()
|
await self._recover()
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
self._loop("control", stop),
|
self._loop(QueueName.CONTROL, stop),
|
||||||
*(self._loop("jobs", stop) for _ in range(self.max_concurrent_jobs)),
|
*(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _loop(self, queue: str, stop: asyncio.Event) -> None:
|
async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
if queue == "jobs" and not await self.opencode.ready():
|
if queue is QueueName.JOBS and not await self.opencode.ready():
|
||||||
await self._wait(stop)
|
await self._wait(stop)
|
||||||
continue
|
continue
|
||||||
task = await self.storage.claim_task(queue)
|
task = await self.repository.claim_task(queue)
|
||||||
if task is None:
|
if task is None:
|
||||||
await self._wait(stop)
|
await self._wait(stop)
|
||||||
continue
|
continue
|
||||||
@@ -78,83 +75,85 @@ class Worker:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.exception(
|
log.exception(
|
||||||
"listener failed",
|
"listener failed",
|
||||||
extra={"task_id": task.id, "listener": task.listener, "queue": queue},
|
extra={
|
||||||
|
"task_id": task.id,
|
||||||
|
"listener": task.kind.value,
|
||||||
|
"queue": queue.value,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
await self.storage.retry_task(task.id, task.attempts, _safe_error(exc))
|
await self.repository.retry_task(task.id, task.attempts, _safe_error(exc))
|
||||||
else:
|
else:
|
||||||
await self.storage.complete_task(task.id)
|
await self.repository.complete_task(task.id)
|
||||||
|
|
||||||
async def _handle(self, task: ListenerTask) -> None:
|
async def _handle(self, task: Task) -> None:
|
||||||
state = await self.host.get(task.job_id)
|
job = await self.repository.get_job(task.job_id)
|
||||||
if state is None:
|
if job is None:
|
||||||
return
|
return
|
||||||
if task.listener == "authorize":
|
match task.kind:
|
||||||
await self._authorize(task, state)
|
case TaskKind.AUTHORIZE:
|
||||||
elif task.listener == "execute":
|
await self._authorize(task, job)
|
||||||
await self._execute(task, state)
|
case TaskKind.EXECUTE:
|
||||||
elif task.listener == "reconcile_comment":
|
await self._execute(task, job)
|
||||||
await self._reconcile(task, state)
|
case TaskKind.RECONCILE_COMMENT:
|
||||||
elif task.listener == "fail_workflow":
|
await self._reconcile(task, job)
|
||||||
await self.storage.fail_job_workflow(state.id)
|
case TaskKind.FAIL_WORKFLOW:
|
||||||
elif task.listener == "abort_sessions":
|
await self.repository.fail_job_workflow(job.id)
|
||||||
await self._abort_job_sessions(state)
|
case TaskKind.ABORT_SESSIONS:
|
||||||
else:
|
await self._abort_job_sessions(job)
|
||||||
raise RuntimeError(f"Unknown listener {task.listener}")
|
case _:
|
||||||
|
raise RuntimeError(f"Unknown task kind {task.kind}")
|
||||||
|
|
||||||
async def _authorize(self, task: ListenerTask, state: JobState) -> None:
|
async def _authorize(self, task: Task, job: Job) -> None:
|
||||||
if state.status is not JobStatus.RECEIVED:
|
if job.status is not JobStatus.RECEIVED:
|
||||||
return
|
return
|
||||||
permitted = await self.gitea.has_write_permission(
|
permitted = await self.gitea.has_write_permission(
|
||||||
state.repo_owner, state.repo_name, state.requester
|
job.repo_owner, job.repo_name, job.requester
|
||||||
)
|
)
|
||||||
event = (
|
event = (
|
||||||
PermissionGranted(job_id=state.id)
|
PermissionGranted(job_id=job.id)
|
||||||
if permitted
|
if permitted
|
||||||
else PermissionDenied(job_id=state.id)
|
else PermissionDenied(job_id=job.id)
|
||||||
)
|
)
|
||||||
outcome = "permission-granted" if permitted else "permission-denied"
|
outcome = "permission-granted" if permitted else "permission-denied"
|
||||||
await self.host.evolve(f"task:{task.id}:{outcome}", event)
|
await self.repository.apply(f"task:{task.id}:{outcome}", event)
|
||||||
|
|
||||||
async def _execute(self, task: ListenerTask, state: JobState) -> None:
|
async def _execute(self, task: Task, job: Job) -> None:
|
||||||
if state.status is JobStatus.RUNNING:
|
if job.status is JobStatus.RUNNING:
|
||||||
await self.host.evolve(
|
await self.repository.apply(
|
||||||
f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id)
|
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if state.status is not JobStatus.QUEUED:
|
if job.status is not JobStatus.QUEUED:
|
||||||
return
|
return
|
||||||
result = await self.host.evolve(
|
result = await self.repository.apply(
|
||||||
f"task:{task.id}:started", JobStarted(job_id=state.id)
|
f"task:{task.id}:started", JobStarted(job_id=job.id)
|
||||||
)
|
)
|
||||||
running = result.state
|
running = result.job
|
||||||
reporter = JobReporter(self.host, state.id, task.id)
|
run = JobRun(self.repository, job.id, task.id)
|
||||||
token = bind_reporter(reporter)
|
|
||||||
try:
|
try:
|
||||||
await self.dispatcher.dispatch(running)
|
body = await dispatch(running, run, self.services)
|
||||||
except JobRejected as exc:
|
except JobRejected as exc:
|
||||||
await self.host.evolve(
|
await self.repository.apply(
|
||||||
f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc))
|
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
latest = await self.host.get(state.id)
|
latest = await self.repository.get_job(job.id)
|
||||||
stage = latest.stage if latest else running.stage
|
stage = latest.stage if latest else running.stage
|
||||||
await self.host.evolve(
|
await self.repository.apply(
|
||||||
f"task:{task.id}:failed",
|
f"task:{task.id}:failed",
|
||||||
JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage),
|
JobFailed(job_id=job.id, error=_safe_error(exc), stage=stage),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.host.evolve(
|
await self.repository.apply(
|
||||||
f"task:{task.id}:completed",
|
f"task:{task.id}:completed",
|
||||||
JobCompleted(
|
JobCompleted(
|
||||||
job_id=state.id,
|
job_id=job.id,
|
||||||
comment_body=reporter.final_body or "Agent job completed.",
|
comment_body=body or "Agent job completed.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
reset_reporter(token)
|
|
||||||
|
|
||||||
async def _reconcile(self, task: ListenerTask, state: JobState) -> None:
|
async def _reconcile(self, task: Task, job: Job) -> None:
|
||||||
latest = await self.host.get(state.id)
|
latest = await self.repository.get_job(job.id)
|
||||||
if latest is None:
|
if latest is None:
|
||||||
return
|
return
|
||||||
body = render_job_comment(latest)
|
body = render_job_comment(latest)
|
||||||
@@ -178,7 +177,7 @@ class Worker:
|
|||||||
comment_id = await self.gitea.create_comment(
|
comment_id = await self.gitea.create_comment(
|
||||||
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
||||||
)
|
)
|
||||||
await self.host.evolve(
|
await self.repository.apply(
|
||||||
f"task:{task.id}:comment:{comment_id}",
|
f"task:{task.id}:comment:{comment_id}",
|
||||||
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
||||||
)
|
)
|
||||||
@@ -187,25 +186,27 @@ class Worker:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _recover(self) -> None:
|
async def _recover(self) -> None:
|
||||||
await self.storage.recover_tasks()
|
await self.repository.recover_tasks()
|
||||||
for state in await self.storage.running_job_states():
|
for job in await self.repository.running_jobs():
|
||||||
await self.host.evolve(
|
await self.repository.apply(
|
||||||
f"recovery:{state.id}:service-restarted",
|
f"recovery:{job.id}:service-restarted",
|
||||||
ServiceRestarted(job_id=state.id),
|
ServiceRestarted(job_id=job.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _abort_job_sessions(self, state: JobState) -> None:
|
async def _abort_job_sessions(self, job: Job) -> None:
|
||||||
sessions: set[tuple[str, Path]] = set()
|
sessions: set[tuple[str, Path]] = set()
|
||||||
workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None
|
workflow = (
|
||||||
|
await self.repository.get_workflow(job.workflow_id) if job.workflow_id else None
|
||||||
|
)
|
||||||
if workflow:
|
if workflow:
|
||||||
sessions.update(
|
sessions.update(
|
||||||
(session, workflow.workspace_path)
|
(session, workflow.workspace_path)
|
||||||
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
|
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
|
||||||
if session
|
if session
|
||||||
)
|
)
|
||||||
elif state.runtime_session_id:
|
elif job.runtime_session_id:
|
||||||
sessions.add(
|
sessions.add(
|
||||||
(state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo")
|
(job.runtime_session_id, self.workspaces_dir / f"fix-{job.id}" / "repo")
|
||||||
)
|
)
|
||||||
for session, workspace in sessions:
|
for session, workspace in sessions:
|
||||||
await self.opencode.abort(session, workspace)
|
await self.opencode.abort(session, workspace)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
"""OpenCode workflow orchestration."""
|
"""Functional workflow orchestration."""
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,81 +1,103 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.engine.repository import Repository
|
||||||
from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo
|
from agentci.gitea import CommentInfo, Gitea, PullRequestInfo
|
||||||
from agentci.adapters.storage import Storage
|
|
||||||
|
|
||||||
|
|
||||||
class ContextBuilder:
|
async def build_issue_context(
|
||||||
def __init__(self, gitea: GiteaClient, storage: Storage) -> None:
|
gitea: Gitea,
|
||||||
self.gitea = gitea
|
repository: Repository,
|
||||||
self.storage = storage
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
) -> str:
|
||||||
|
issue, comments, operational = await asyncio.gather(
|
||||||
|
gitea.issue(owner, repo, number),
|
||||||
|
gitea.issue_comments(owner, repo, number),
|
||||||
|
repository.operational_comment_ids(owner, repo, number),
|
||||||
|
)
|
||||||
|
discussion = "\n\n".join(
|
||||||
|
_format_comment(comment) for comment in comments if comment.id not in operational
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Repository: {owner}/{repo}\n"
|
||||||
|
f"Issue: #{number} \u2014 {issue.title}\n"
|
||||||
|
f"State: {issue.state}\n\n"
|
||||||
|
f"## Issue body\n{issue.body or '(empty)'}\n\n"
|
||||||
|
f"## Discussion\n{discussion or '(none)'}"
|
||||||
|
)
|
||||||
|
|
||||||
async def issue_context(self, owner: str, repo: str, number: int) -> str:
|
|
||||||
issue = await self.gitea.issue(owner, repo, number)
|
async def build_pull_request_context(
|
||||||
comments = await self.gitea.issue_comments(owner, repo, number)
|
gitea: Gitea,
|
||||||
operational = await self.storage.operational_comment_ids(owner, repo, number)
|
owner: str,
|
||||||
discussion = "\n\n".join(
|
repo: str,
|
||||||
_format_comment(comment) for comment in comments if comment.id not in operational
|
number: int,
|
||||||
|
) -> tuple[PullRequestInfo, str]:
|
||||||
|
pull, timeline, reviews, commits = await asyncio.gather(
|
||||||
|
gitea.pull_request(owner, repo, number),
|
||||||
|
gitea.issue_comments(owner, repo, number),
|
||||||
|
gitea.pull_reviews(owner, repo, number),
|
||||||
|
gitea.pull_commits(owner, repo, number),
|
||||||
|
)
|
||||||
|
review_text = await _format_reviews(gitea, owner, repo, number, reviews)
|
||||||
|
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
|
||||||
|
commit_text = "\n".join(
|
||||||
|
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
|
||||||
|
for item in commits
|
||||||
|
)
|
||||||
|
context = (
|
||||||
|
f"Repository: {owner}/{repo}\n"
|
||||||
|
f"Pull request: #{number} \u2014 {pull.title}\n"
|
||||||
|
f"State: {pull.state}; merged: {pull.merged}\n"
|
||||||
|
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
|
||||||
|
f"{pull.head_branch} @ {pull.head_sha}\n\n"
|
||||||
|
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
|
||||||
|
f"## Commits\n{commit_text or '(none)'}\n\n"
|
||||||
|
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
|
||||||
|
f"## Formal and inline reviews\n{review_text or '(none)'}"
|
||||||
|
)
|
||||||
|
return pull, context
|
||||||
|
|
||||||
|
|
||||||
|
async def _format_reviews(
|
||||||
|
gitea: Gitea,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
reviews: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
details = [
|
||||||
|
(
|
||||||
|
int(review["id"]),
|
||||||
|
review.get("user", {}).get("login", "unknown"),
|
||||||
|
review.get("state", "unknown"),
|
||||||
|
review.get("body") or "(empty)",
|
||||||
)
|
)
|
||||||
return (
|
for review in reviews
|
||||||
f"Repository: {owner}/{repo}\n"
|
]
|
||||||
f"Issue: #{number} — {issue.title}\n"
|
comment_groups = await asyncio.gather(
|
||||||
f"State: {issue.state}\n\n"
|
*(
|
||||||
f"## Issue body\n{issue.body or '(empty)'}\n\n"
|
gitea.review_comments(owner, repo, number, review_id)
|
||||||
f"## Discussion\n{discussion or '(none)'}"
|
for review_id, _, _, _ in details
|
||||||
)
|
)
|
||||||
|
)
|
||||||
async def pull_request_context(
|
sections: list[str] = []
|
||||||
self, owner: str, repo: str, number: int
|
for (review_id, author, state, body), comments in zip(
|
||||||
) -> tuple[PullRequestInfo, str]:
|
details, comment_groups, strict=True
|
||||||
pull = await self.gitea.pull_request(owner, repo, number)
|
):
|
||||||
timeline = await self.gitea.issue_comments(owner, repo, number)
|
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
|
||||||
reviews = await self.gitea.pull_reviews(owner, repo, number)
|
for comment in comments:
|
||||||
commits = await self.gitea.pull_commits(owner, repo, number)
|
path = comment.get("path") or "unknown file"
|
||||||
review_text = await self._format_reviews(owner, repo, number, reviews)
|
line = comment.get("new_position") or comment.get("old_position") or "?"
|
||||||
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
|
text = comment.get("body") or ""
|
||||||
commit_text = "\n".join(
|
lines.append(f"- `{path}:{line}`: {text}")
|
||||||
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
|
sections.append("\n".join(lines))
|
||||||
for item in commits
|
return "\n\n".join(sections)
|
||||||
)
|
|
||||||
context = (
|
|
||||||
f"Repository: {owner}/{repo}\n"
|
|
||||||
f"Pull request: #{number} — {pull.title}\n"
|
|
||||||
f"State: {pull.state}; merged: {pull.merged}\n"
|
|
||||||
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
|
|
||||||
f"{pull.head_branch} @ {pull.head_sha}\n\n"
|
|
||||||
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
|
|
||||||
f"## Commits\n{commit_text or '(none)'}\n\n"
|
|
||||||
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
|
|
||||||
f"## Formal and inline reviews\n{review_text or '(none)'}"
|
|
||||||
)
|
|
||||||
return pull, context
|
|
||||||
|
|
||||||
async def _format_reviews(
|
|
||||||
self,
|
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
number: int,
|
|
||||||
reviews: list[dict[str, Any]],
|
|
||||||
) -> str:
|
|
||||||
sections: list[str] = []
|
|
||||||
for review in reviews:
|
|
||||||
review_id = int(review["id"])
|
|
||||||
author = review.get("user", {}).get("login", "unknown")
|
|
||||||
state = review.get("state", "unknown")
|
|
||||||
body = review.get("body") or "(empty)"
|
|
||||||
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
|
|
||||||
for comment in await self.gitea.review_comments(owner, repo, number, review_id):
|
|
||||||
path = comment.get("path") or "unknown file"
|
|
||||||
line = comment.get("new_position") or comment.get("old_position") or "?"
|
|
||||||
text = comment.get("body") or ""
|
|
||||||
lines.append(f"- `{path}:{line}`: {text}")
|
|
||||||
sections.append("\n".join(lines))
|
|
||||||
return "\n\n".join(sections)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_comment(comment: CommentInfo) -> str:
|
def _format_comment(comment: CommentInfo) -> str:
|
||||||
return f"### {comment.author} at {comment.created_at}\n{comment.body}"
|
return f"### {comment.author} at {comment.created_at}\n{comment.body}"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
|
||||||
@@ -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."
|
|
||||||
)
|
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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
@@ -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."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from agentci.config import Settings
|
||||||
|
from agentci.development import DevelopmentEnvironment
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.git import Git
|
||||||
|
from agentci.gitea import Gitea
|
||||||
|
from agentci.opencode import OpenCode
|
||||||
|
from agentci.prompts import PromptLibrary
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkflowServices:
|
||||||
|
settings: Settings
|
||||||
|
repository: Repository
|
||||||
|
gitea: Gitea
|
||||||
|
git: Git
|
||||||
|
opencode: OpenCode
|
||||||
|
prompts: PromptLibrary
|
||||||
|
development: DevelopmentEnvironment
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
import agentci.app as app_module
|
||||||
|
|
||||||
|
|
||||||
|
class BlockingWorker:
|
||||||
|
def __init__(self, events: list[str]) -> None:
|
||||||
|
self.events = events
|
||||||
|
self.started = asyncio.Event()
|
||||||
|
|
||||||
|
async def run(self, stop: asyncio.Event) -> None:
|
||||||
|
self.events.append("worker-started")
|
||||||
|
self.started.set()
|
||||||
|
try:
|
||||||
|
await stop.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.events.append(f"worker-cancelled:{stop.is_set()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class FailingWorker:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.started = asyncio.Event()
|
||||||
|
|
||||||
|
async def run(self, _stop: asyncio.Event) -> None:
|
||||||
|
self.started.set()
|
||||||
|
raise RuntimeError("worker failed")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRuntime:
|
||||||
|
def __init__(self, worker: object, events: list[str]) -> None:
|
||||||
|
self.worker = worker
|
||||||
|
self.events = events
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.events.append("runtime-closed")
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
worker = BlockingWorker(events)
|
||||||
|
runtime = FakeRuntime(worker, events)
|
||||||
|
selected_settings = SimpleNamespace(name="selected")
|
||||||
|
built_with: list[object] = []
|
||||||
|
configured: list[bool] = []
|
||||||
|
|
||||||
|
async def build(settings: object) -> FakeRuntime:
|
||||||
|
built_with.append(settings)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "build_runtime", build)
|
||||||
|
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
|
||||||
|
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
await worker.started.wait()
|
||||||
|
assert application.state.runtime is runtime
|
||||||
|
assert not runtime.closed
|
||||||
|
|
||||||
|
assert built_with == [selected_settings]
|
||||||
|
assert configured == [True]
|
||||||
|
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifespan_closes_runtime_when_worker_task_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
worker = FailingWorker()
|
||||||
|
runtime = FakeRuntime(worker, events)
|
||||||
|
|
||||||
|
async def build(_settings: object) -> FakeRuntime:
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "build_runtime", build)
|
||||||
|
monkeypatch.setattr(app_module, "configure_logging", lambda: None)
|
||||||
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="worker failed"):
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
await worker.started.wait()
|
||||||
|
|
||||||
|
assert runtime.closed
|
||||||
|
assert events == ["runtime-closed"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
configured: list[bool] = []
|
||||||
|
|
||||||
|
async def fail_build(_settings: object) -> None:
|
||||||
|
raise RuntimeError("database unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "build_runtime", fail_build)
|
||||||
|
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
|
||||||
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
pytest.fail("startup failure must prevent serving requests")
|
||||||
|
|
||||||
|
assert configured == [True]
|
||||||
|
assert not hasattr(application.state, "runtime")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_global_exception_handler_returns_safe_json() -> None:
|
||||||
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
@application.get("/explode")
|
||||||
|
async def explode() -> None:
|
||||||
|
raise RuntimeError("sensitive provider detail")
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
response = await client.get("/explode")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
|
||||||
+348
-89
@@ -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
@@ -1,13 +1,17 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.adapters.codegraph import CodeGraphClient
|
import pytest
|
||||||
|
|
||||||
|
from agentci.codegraph import CodeGraph, CodeGraphError
|
||||||
|
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
returncode = 0
|
def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
async def communicate(self) -> tuple[bytes, bytes]:
|
async def communicate(self) -> tuple[bytes, bytes]:
|
||||||
return b"", b""
|
return b"", self.stderr
|
||||||
|
|
||||||
|
|
||||||
async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
||||||
@@ -23,11 +27,11 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
|||||||
return FakeProcess()
|
return FakeProcess()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
|
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||||
create_subprocess_exec,
|
create_subprocess_exec,
|
||||||
)
|
)
|
||||||
|
|
||||||
await CodeGraphClient().prepare(workspace)
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
assert calls == [("codegraph", "init", str(workspace))]
|
assert calls == [("codegraph", "init", str(workspace))]
|
||||||
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
||||||
@@ -49,11 +53,86 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
|
|||||||
return FakeProcess()
|
return FakeProcess()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
|
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||||
create_subprocess_exec,
|
create_subprocess_exec,
|
||||||
)
|
)
|
||||||
|
|
||||||
await CodeGraphClient().prepare(workspace)
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
assert calls == [("codegraph", "sync", str(workspace))]
|
assert calls == [("codegraph", "sync", str(workspace))]
|
||||||
assert exclude.read_text() == "# local excludes\n.codegraph/\n"
|
assert exclude.read_text() == "# local excludes\n.codegraph/\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("initial", "expected"),
|
||||||
|
[
|
||||||
|
("*.pyc", "*.pyc\n.codegraph/\n"),
|
||||||
|
("# .codegraph/ is documented here\n", "# .codegraph/ is documented here\n.codegraph/\n"),
|
||||||
|
(" .codegraph/ \n", " .codegraph/ \n"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_handles_exclude_file_boundaries(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
initial: str,
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
exclude = workspace / ".git" / "info" / "exclude"
|
||||||
|
exclude.parent.mkdir(parents=True)
|
||||||
|
exclude.write_text(initial)
|
||||||
|
|
||||||
|
async def create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||||
|
create_subprocess_exec,
|
||||||
|
)
|
||||||
|
|
||||||
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
|
assert exclude.read_text() == expected
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
workspace.mkdir()
|
||||||
|
|
||||||
|
async def create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
raise FileNotFoundError(2, "No such file or directory", "codegraph")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||||
|
create_subprocess_exec,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised:
|
||||||
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
|
assert isinstance(raised.value.__cause__, FileNotFoundError)
|
||||||
|
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
workspace.mkdir()
|
||||||
|
stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail"
|
||||||
|
|
||||||
|
async def create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
return FakeProcess(returncode=7, stderr=stderr)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||||
|
create_subprocess_exec,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CodeGraphError, match="codegraph init failed") as raised:
|
||||||
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
|
message = str(raised.value)
|
||||||
|
assert "discarded-prefix" not in message
|
||||||
|
assert "useful-tail" in message
|
||||||
|
assert len(message.removeprefix("codegraph init failed: ")) == 1000
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
|
from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
|
||||||
from agentci.domain.models import CommandName, JobKind
|
from agentci.engine.model import CommandName, JobKind
|
||||||
|
|
||||||
|
|
||||||
def test_ignores_non_commands() -> None:
|
def test_ignores_non_commands() -> None:
|
||||||
@@ -14,8 +14,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
|
|||||||
name: CommandName, line_breaks: int
|
name: CommandName, line_breaks: int
|
||||||
) -> None:
|
) -> None:
|
||||||
command = parse_command(
|
command = parse_command(
|
||||||
f"/agent {name.value}{'\n' * line_breaks}"
|
f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests"
|
||||||
"focus on the API\nand add tests"
|
|
||||||
)
|
)
|
||||||
assert command is not None
|
assert command is not None
|
||||||
assert command.name is name
|
assert command.name is name
|
||||||
@@ -26,10 +25,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines(
|
|||||||
def test_all_commands_accept_crlf_separated_multiline_messages(
|
def test_all_commands_accept_crlf_separated_multiline_messages(
|
||||||
name: CommandName,
|
name: CommandName,
|
||||||
) -> None:
|
) -> None:
|
||||||
command = parse_command(
|
command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests")
|
||||||
f"/agent {name.value}\r\n\r\n\r\n"
|
|
||||||
"focus on the API\r\nand add tests"
|
|
||||||
)
|
|
||||||
assert command is not None
|
assert command is not None
|
||||||
assert command.name is name
|
assert command.name is name
|
||||||
assert command.message == "focus on the API\r\nand add tests"
|
assert command.message == "focus on the API\r\nand add tests"
|
||||||
|
|||||||
+132
-43
@@ -1,61 +1,150 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from agentci.config import Settings
|
from agentci.config import Settings
|
||||||
|
|
||||||
|
|
||||||
def test_parses_comma_delimited_install_scripts() -> None:
|
@pytest.fixture(autouse=True)
|
||||||
settings = Settings(
|
def isolate_agentci_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
_env_file=None, # type: ignore[call-arg]
|
for name in tuple(os.environ):
|
||||||
install_scripts=" python, dotnet, company-tools, ",
|
if name.startswith("AGENTCI_"):
|
||||||
|
monkeypatch.delenv(name)
|
||||||
|
|
||||||
|
|
||||||
|
def settings(**overrides: object) -> Settings:
|
||||||
|
return Settings(_env_file=None, **overrides) # type: ignore[arg-type,call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_values_from_an_isolated_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
||||||
|
monkeypatch.setenv("AGENTCI_MAX_CONCURRENT_JOBS", "7")
|
||||||
|
|
||||||
|
value = settings()
|
||||||
|
|
||||||
|
assert value.install_scripts == ["python", "dotnet"]
|
||||||
|
assert value.max_concurrent_jobs == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["gitea_url", "opencode_url"])
|
||||||
|
def test_normalizes_service_urls(field: str) -> None:
|
||||||
|
value = settings(**{field: "https://service.example/base///"})
|
||||||
|
|
||||||
|
assert getattr(value, field) == "https://service.example/base"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "boundaries"),
|
||||||
|
[
|
||||||
|
("plan_review_rounds", (1, 20)),
|
||||||
|
("implement_review_rounds", (1, 20)),
|
||||||
|
("turn_timeout_seconds", (60,)),
|
||||||
|
("install_script_timeout_seconds", (1,)),
|
||||||
|
("worker_poll_seconds", (0.1,)),
|
||||||
|
("max_concurrent_jobs", (1, 32)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_accepts_documented_numeric_boundaries(
|
||||||
|
field: str, boundaries: tuple[int | float, ...]
|
||||||
|
) -> None:
|
||||||
|
for boundary in boundaries:
|
||||||
|
assert getattr(settings(**{field: boundary}), field) == boundary
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
("plan_review_rounds", 0),
|
||||||
|
("plan_review_rounds", 21),
|
||||||
|
("implement_review_rounds", 0),
|
||||||
|
("implement_review_rounds", 21),
|
||||||
|
("turn_timeout_seconds", 59),
|
||||||
|
("install_script_timeout_seconds", 0),
|
||||||
|
("worker_poll_seconds", 0.09),
|
||||||
|
("max_concurrent_jobs", 0),
|
||||||
|
("max_concurrent_jobs", 33),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_values_outside_numeric_boundaries(field: str, value: int | float) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
settings(**{field: value})
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_and_strips_secret_files(tmp_path: Path) -> None:
|
||||||
|
token_file = tmp_path / "token"
|
||||||
|
webhook_file = tmp_path / "webhook"
|
||||||
|
password_file = tmp_path / "password"
|
||||||
|
token_file.write_text(" gitea-token\n")
|
||||||
|
webhook_file.write_text(" webhook-secret \n")
|
||||||
|
password_file.write_text("opencode-password\n")
|
||||||
|
value = settings(
|
||||||
|
gitea_token_file=token_file,
|
||||||
|
webhook_secret_file=webhook_file,
|
||||||
|
opencode_server_password_file=password_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert settings.install_scripts == ["python", "dotnet", "company-tools"]
|
assert value.gitea_token == "gitea-token"
|
||||||
|
assert value.webhook_secret == b"webhook-secret"
|
||||||
|
assert value.opencode_server_password == "opencode-password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_secret_file_has_actionable_error(tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "missing-token"
|
||||||
|
value = settings(gitea_token_file=missing)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Cannot read Gitea token") as raised:
|
||||||
|
_ = value.gitea_token
|
||||||
|
|
||||||
|
assert str(missing) in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_secret_file_is_rejected(tmp_path: Path) -> None:
|
||||||
|
empty = tmp_path / "webhook"
|
||||||
|
empty.write_text(" \n")
|
||||||
|
value = settings(webhook_secret_file=empty)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="webhook secret file") as raised:
|
||||||
|
_ = value.webhook_secret
|
||||||
|
|
||||||
|
assert str(empty) in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_state_paths_follow_data_directory(tmp_path: Path) -> None:
|
||||||
|
data_dir = tmp_path / "state"
|
||||||
|
value = settings(data_dir=data_dir)
|
||||||
|
|
||||||
|
assert value.database_path == data_dir / "agentci.sqlite3"
|
||||||
|
assert value.workspaces_dir == data_dir / "workspaces"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_comma_delimited_install_scripts() -> None:
|
||||||
|
value = settings(install_scripts=" python, dotnet, company-tools, ")
|
||||||
|
|
||||||
|
assert value.install_scripts == ["python", "dotnet", "company-tools"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
|
@pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"])
|
||||||
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
|
def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
Settings(_env_file=None, install_scripts=value) # type: ignore[call-arg]
|
settings(install_scripts=value)
|
||||||
|
|
||||||
|
|
||||||
def test_empty_install_scripts_disable_setup() -> None:
|
@pytest.mark.parametrize("value", ["", None, []])
|
||||||
settings = Settings(
|
def test_empty_install_scripts_disable_setup(value: object) -> None:
|
||||||
_env_file=None, # type: ignore[call-arg]
|
assert settings(install_scripts=value).install_scripts == []
|
||||||
install_scripts="",
|
|
||||||
|
|
||||||
|
def test_agent_defaults_select_expected_capacity_and_research_models() -> None:
|
||||||
|
value = settings()
|
||||||
|
|
||||||
|
assert value.max_concurrent_jobs == 2
|
||||||
|
assert (value.explore_model, value.explore_variant) == (
|
||||||
|
"openai/gpt-5.6-luna",
|
||||||
|
"low",
|
||||||
)
|
)
|
||||||
assert settings.install_scripts == []
|
assert value.research_variant == "high"
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_research_variant_to_high(monkeypatch) -> None:
|
|
||||||
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
|
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
|
||||||
assert settings.research_variant == "high"
|
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_explore_agent_to_luna_low() -> None:
|
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
|
||||||
assert settings.explore_model == "openai/gpt-5.6-luna"
|
|
||||||
assert settings.explore_variant == "low"
|
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_to_two_concurrent_jobs() -> None:
|
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
|
||||||
assert settings.max_concurrent_jobs == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [0, 33])
|
|
||||||
def test_rejects_unsafe_job_concurrency(value: int) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
Settings(_env_file=None, max_concurrent_jobs=value) # type: ignore[call-arg]
|
|
||||||
|
|
||||||
|
|
||||||
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
|
||||||
|
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
|
||||||
|
|
||||||
assert settings.install_scripts == ["python", "dotnet"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -63,4 +152,4 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) ->
|
|||||||
)
|
)
|
||||||
def test_requires_provider_qualified_opencode_models(field: str) -> None:
|
def test_requires_provider_qualified_opencode_models(field: str) -> None:
|
||||||
with pytest.raises(ValidationError, match="provider/model"):
|
with pytest.raises(ValidationError, match="provider/model"):
|
||||||
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg]
|
settings(**{field: "model-only"})
|
||||||
|
|||||||
+193
-6
@@ -1,8 +1,11 @@
|
|||||||
from agentci.adapters.gitea_models import CommentInfo, IssueInfo
|
from typing import cast
|
||||||
from agentci.workflows.context import ContextBuilder
|
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.gitea import CommentInfo, Gitea, IssueInfo, PullRequestInfo
|
||||||
|
from agentci.workflows.context import build_issue_context, build_pull_request_context
|
||||||
|
|
||||||
|
|
||||||
class FakeGitea:
|
class FakeIssueGitea:
|
||||||
async def issue(self, *_args):
|
async def issue(self, *_args):
|
||||||
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
|
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
|
||||||
|
|
||||||
@@ -13,15 +16,199 @@ class FakeGitea:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class FakeStorage:
|
class FakeRepository:
|
||||||
async def operational_comment_ids(self, *_args):
|
async def operational_comment_ids(self, *_args):
|
||||||
return {2}
|
return {2}
|
||||||
|
|
||||||
|
|
||||||
|
class FakePullRequestGitea:
|
||||||
|
async def pull_request(self, *_args):
|
||||||
|
return PullRequestInfo(
|
||||||
|
number=3,
|
||||||
|
title="Fix widget",
|
||||||
|
body="Fixes the failure.",
|
||||||
|
state="open",
|
||||||
|
merged=False,
|
||||||
|
base_branch="main",
|
||||||
|
head_branch="fix-widget",
|
||||||
|
head_sha="abcdef1234567890",
|
||||||
|
head_owner="alice",
|
||||||
|
head_repo="repo",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def issue_comments(self, *_args):
|
||||||
|
return [CommentInfo(3, "bob", "Please add a test.", "2026-01-03")]
|
||||||
|
|
||||||
|
async def pull_reviews(self, *_args):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"user": {"login": "carol"},
|
||||||
|
"state": "REQUEST_CHANGES",
|
||||||
|
"body": "One issue remains.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def review_comments(self, *_args):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"path": "src/widget.py",
|
||||||
|
"new_position": 12,
|
||||||
|
"body": "Handle the empty value.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def pull_commits(self, *_args):
|
||||||
|
return [{"sha": "abcdef1234567890", "commit": {"message": "Fix widget"}}]
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyIssueGitea:
|
||||||
|
async def issue(self, *_args):
|
||||||
|
return IssueInfo(number=5, title="Empty issue", body="", state="open")
|
||||||
|
|
||||||
|
async def issue_comments(self, *_args):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyRepository:
|
||||||
|
async def operational_comment_ids(self, *_args):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyPullRequestGitea:
|
||||||
|
async def pull_request(self, *_args):
|
||||||
|
return PullRequestInfo(
|
||||||
|
number=6,
|
||||||
|
title="Empty pull request",
|
||||||
|
body="",
|
||||||
|
state="open",
|
||||||
|
merged=False,
|
||||||
|
base_branch="main",
|
||||||
|
head_branch="empty",
|
||||||
|
head_sha="1234567890abcdef",
|
||||||
|
head_owner="alice",
|
||||||
|
head_repo="repo",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def issue_comments(self, *_args):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def pull_reviews(self, *_args):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def pull_commits(self, *_args):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def review_comments(self, *_args):
|
||||||
|
raise AssertionError("review comments should not be requested without reviews")
|
||||||
|
|
||||||
|
|
||||||
|
class OrderedPullRequestGitea(FakePullRequestGitea):
|
||||||
|
async def issue_comments(self, *_args):
|
||||||
|
return [
|
||||||
|
CommentInfo(1, "alice", "First timeline comment", "2026-01-01"),
|
||||||
|
CommentInfo(2, "bob", "Second timeline comment", "2026-01-02"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def pull_reviews(self, *_args):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"user": {"login": "carol"},
|
||||||
|
"state": "REQUEST_CHANGES",
|
||||||
|
"body": "First formal review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"user": {"login": "dave"},
|
||||||
|
"state": "APPROVED",
|
||||||
|
"body": "Second formal review",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def review_comments(self, *_args):
|
||||||
|
review_id = _args[-1]
|
||||||
|
if review_id == 10:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"path": "src/old.py",
|
||||||
|
"new_position": None,
|
||||||
|
"old_position": 21,
|
||||||
|
"body": "Old-side position",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"path": "src/new.py",
|
||||||
|
"new_position": 34,
|
||||||
|
"body": "New-side position",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def pull_commits(self, *_args):
|
||||||
|
return [
|
||||||
|
{"sha": "111111111111aaaa", "commit": {"message": "First commit"}},
|
||||||
|
{"sha": "222222222222bbbb", "commit": {"message": "Second commit"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_issue_context_excludes_operational_comments() -> None:
|
async def test_issue_context_excludes_operational_comments() -> None:
|
||||||
builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type]
|
context = await build_issue_context(
|
||||||
context = await builder.issue_context("org", "repo", 2)
|
cast(Gitea, FakeIssueGitea()),
|
||||||
|
cast(Repository, FakeRepository()),
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
assert "Broken widget" in context
|
assert "Broken widget" in context
|
||||||
assert "Details" in context
|
assert "Details" in context
|
||||||
assert "Agent job queued" not in context
|
assert "Agent job queued" not in context
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pull_request_context_includes_feedback_and_commits() -> None:
|
||||||
|
pull, context = await build_pull_request_context(
|
||||||
|
cast(Gitea, FakePullRequestGitea()), "org", "repo", 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pull.number == 3
|
||||||
|
assert "Please add a test." in context
|
||||||
|
assert "One issue remains." in context
|
||||||
|
assert "`src/widget.py:12`: Handle the empty value." in context
|
||||||
|
assert "abcdef123456 Fix widget" in context
|
||||||
|
|
||||||
|
|
||||||
|
async def test_issue_context_labels_empty_body_and_discussion() -> None:
|
||||||
|
context = await build_issue_context(
|
||||||
|
cast(Gitea, EmptyIssueGitea()),
|
||||||
|
cast(Repository, EmptyRepository()),
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Issue body\n(empty)" in context
|
||||||
|
assert "## Discussion\n(none)" in context
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pull_request_context_labels_empty_sections() -> None:
|
||||||
|
_, context = await build_pull_request_context(
|
||||||
|
cast(Gitea, EmptyPullRequestGitea()), "org", "repo", 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Pull request body\n(empty)" in context
|
||||||
|
assert "## Commits\n(none)" in context
|
||||||
|
assert "## Timeline discussion\n(none)" in context
|
||||||
|
assert "## Formal and inline reviews\n(none)" in context
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pull_request_context_preserves_source_order_and_positions() -> None:
|
||||||
|
_, context = await build_pull_request_context(
|
||||||
|
cast(Gitea, OrderedPullRequestGitea()), "org", "repo", 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.index("First timeline comment") < context.index("Second timeline comment")
|
||||||
|
assert context.index("111111111111 First commit") < context.index("222222222222 Second commit")
|
||||||
|
assert context.index("Review 10 by carol") < context.index("Review 11 by dave")
|
||||||
|
assert "`src/old.py:21`: Old-side position" in context
|
||||||
|
assert "`src/new.py:34`: New-side position" in context
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agentci.adapters.development import (
|
from agentci.development import (
|
||||||
DevelopmentEnvironment,
|
DevelopmentEnvironment,
|
||||||
DevelopmentEnvironmentError,
|
DevelopmentEnvironmentError,
|
||||||
)
|
)
|
||||||
@@ -37,7 +37,7 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
|
|||||||
development = environment(tmp_path, ["first", "second"])
|
development = environment(tmp_path, ["first", "second"])
|
||||||
script(
|
script(
|
||||||
development.scripts_dir / "first",
|
development.scripts_dir / "first",
|
||||||
"printf 'first:%s:%s\\n' \"$DEV_TOOLS_DIR\" \"${AGENTCI_SECRET-unset}\" >> order",
|
'printf \'first:%s:%s\\n\' "$DEV_TOOLS_DIR" "${AGENTCI_SECRET-unset}" >> order',
|
||||||
)
|
)
|
||||||
script(development.scripts_dir / "second", "printf 'second\\n' >> order")
|
script(development.scripts_dir / "second", "printf 'second\\n' >> order")
|
||||||
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
|
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
|
||||||
@@ -112,6 +112,76 @@ async def test_reports_script_failure_output(tmp_path) -> None:
|
|||||||
await development.prepare(workspace)
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reports_missing_install_script(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
development = environment(tmp_path, ["missing"])
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
DevelopmentEnvironmentError,
|
||||||
|
match=r"Install script 'missing' was not found",
|
||||||
|
):
|
||||||
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stops_before_second_script_after_failure(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
development = environment(tmp_path, ["first", "second"])
|
||||||
|
script(development.scripts_dir / "first", "printf 'first\n' > first-ran; exit 3")
|
||||||
|
script(development.scripts_dir / "second", "printf 'second\n' > second-ran")
|
||||||
|
|
||||||
|
with pytest.raises(DevelopmentEnvironmentError, match="exited with 3"):
|
||||||
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
assert (workspace / "first-ran").read_text() == "first\n"
|
||||||
|
assert not (workspace / "second-ran").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
development = environment(tmp_path, ["verbose"])
|
||||||
|
script(
|
||||||
|
development.scripts_dir / "verbose",
|
||||||
|
"printf 'discarded-prefix' >&2; "
|
||||||
|
'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; '
|
||||||
|
"printf 'useful-tail' >&2; exit 9",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DevelopmentEnvironmentError) as raised:
|
||||||
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
message = str(raised.value)
|
||||||
|
assert "discarded-prefix" not in message
|
||||||
|
assert message.endswith("useful-tail")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wraps_subprocess_start_error(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
development = environment(tmp_path, ["broken"])
|
||||||
|
script(development.scripts_dir / "broken", "true")
|
||||||
|
|
||||||
|
async def create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
raise OSError("exec unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agentci.development.asyncio.create_subprocess_exec",
|
||||||
|
create_subprocess_exec,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
DevelopmentEnvironmentError,
|
||||||
|
match="Could not run install script 'broken': exec unavailable",
|
||||||
|
) as raised:
|
||||||
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
assert isinstance(raised.value.__cause__, OSError)
|
||||||
|
|
||||||
|
|
||||||
async def test_times_out_install_script(tmp_path) -> None:
|
async def test_times_out_install_script(tmp_path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
workspace.mkdir()
|
workspace.mkdir()
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.git import Git, GitError
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stdout: bytes = b"",
|
||||||
|
stderr: bytes = b"",
|
||||||
|
returncode: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
async def communicate(self) -> tuple[bytes, bytes]:
|
||||||
|
return self.stdout, self.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessRecorder:
|
||||||
|
def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None:
|
||||||
|
self.outcomes = list(outcomes)
|
||||||
|
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess:
|
||||||
|
self.calls.append((args, kwargs))
|
||||||
|
outcome = self.outcomes.pop(0)
|
||||||
|
if isinstance(outcome, OSError):
|
||||||
|
raise outcome
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commands(self) -> list[tuple[Any, ...]]:
|
||||||
|
return [args for args, _ in self.calls]
|
||||||
|
|
||||||
|
|
||||||
|
def git_client(tmp_path: Path) -> Git:
|
||||||
|
return Git(
|
||||||
|
gitea_url="https://git.example.test/",
|
||||||
|
username="agent-user",
|
||||||
|
token="secret-token",
|
||||||
|
askpass_path=tmp_path / "askpass.sh",
|
||||||
|
commit_name="Agent CI",
|
||||||
|
commit_email="agent@example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_recorder(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
outcomes: Sequence[FakeProcess | OSError],
|
||||||
|
) -> SubprocessRecorder:
|
||||||
|
recorder = SubprocessRecorder(outcomes)
|
||||||
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", recorder)
|
||||||
|
return recorder
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clone_uses_authenticated_remote_and_returns_head(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
for name in (
|
||||||
|
"GIT_ASKPASS",
|
||||||
|
"GIT_TERMINAL_PROMPT",
|
||||||
|
"AGENTCI_GIT_USERNAME",
|
||||||
|
"AGENTCI_GIT_PASSWORD",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(stdout=b"abc123\n")],
|
||||||
|
)
|
||||||
|
destination = tmp_path / "workspaces" / "repo"
|
||||||
|
|
||||||
|
sha = await git_client(tmp_path).clone("org", "repo", "main", destination)
|
||||||
|
|
||||||
|
assert sha == "abc123"
|
||||||
|
assert destination.parent.is_dir()
|
||||||
|
assert recorder.commands == [
|
||||||
|
(
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch",
|
||||||
|
"main",
|
||||||
|
"--single-branch",
|
||||||
|
"https://git.example.test/org/repo.git",
|
||||||
|
str(destination),
|
||||||
|
),
|
||||||
|
("git", "rev-parse", "HEAD"),
|
||||||
|
]
|
||||||
|
clone_kwargs = recorder.calls[0][1]
|
||||||
|
assert clone_kwargs["cwd"] == destination.parent
|
||||||
|
assert clone_kwargs["stdout"] is asyncio.subprocess.PIPE
|
||||||
|
assert clone_kwargs["stderr"] is asyncio.subprocess.PIPE
|
||||||
|
assert (
|
||||||
|
clone_kwargs["env"]
|
||||||
|
| {
|
||||||
|
"GIT_ASKPASS": str(tmp_path / "askpass.sh"),
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"AGENTCI_GIT_USERNAME": "agent-user",
|
||||||
|
"AGENTCI_GIT_PASSWORD": "secret-token",
|
||||||
|
}
|
||||||
|
== clone_kwargs["env"]
|
||||||
|
)
|
||||||
|
current_sha_environment = recorder.calls[1][1]["env"]
|
||||||
|
assert "AGENTCI_GIT_PASSWORD" not in current_sha_environment
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_branch_resets_and_cleans_before_returning_sha(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(), FakeProcess(), FakeProcess(stdout=b"new-sha\n")],
|
||||||
|
)
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
|
||||||
|
sha = await git_client(tmp_path).sync_branch(workspace, "feature")
|
||||||
|
|
||||||
|
assert sha == "new-sha"
|
||||||
|
assert recorder.commands == [
|
||||||
|
(
|
||||||
|
"git",
|
||||||
|
"fetch",
|
||||||
|
"origin",
|
||||||
|
"refs/heads/feature:refs/remotes/origin/feature",
|
||||||
|
),
|
||||||
|
("git", "reset", "--hard", "origin/feature"),
|
||||||
|
("git", "clean", "-fd"),
|
||||||
|
("git", "rev-parse", "HEAD"),
|
||||||
|
]
|
||||||
|
assert all(call[1]["cwd"] == workspace for call in recorder.calls)
|
||||||
|
assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_branch_status_and_diff_commands(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(stdout=b" M src/app.py\n"), FakeProcess()],
|
||||||
|
)
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
git = git_client(tmp_path)
|
||||||
|
|
||||||
|
await git.create_branch(workspace, "agent/issue-1")
|
||||||
|
changed = await git.has_changes(workspace)
|
||||||
|
await git.diff_check(workspace)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert recorder.commands == [
|
||||||
|
("git", "switch", "-c", "agent/issue-1"),
|
||||||
|
("git", "status", "--porcelain"),
|
||||||
|
("git", "diff", "--check"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_has_changes_is_false_for_clean_status(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
install_recorder(monkeypatch, [FakeProcess(stdout=b"\n")])
|
||||||
|
|
||||||
|
assert await git_client(tmp_path).has_changes(tmp_path / "repo") is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_commit_stages_all_changes_sets_identity_and_returns_sha(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(), FakeProcess(stdout=b"commit-sha\n")],
|
||||||
|
)
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
|
||||||
|
sha = await git_client(tmp_path).commit(workspace, "agent: Fix widget")
|
||||||
|
|
||||||
|
assert sha == "commit-sha"
|
||||||
|
assert recorder.commands == [
|
||||||
|
("git", "add", "-A"),
|
||||||
|
(
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"user.name=Agent CI",
|
||||||
|
"-c",
|
||||||
|
"user.email=agent@example.test",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"agent: Fix widget",
|
||||||
|
),
|
||||||
|
("git", "rev-parse", "HEAD"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_push_command_supports_initial_and_existing_branches(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
recorder = install_recorder(monkeypatch, [FakeProcess(), FakeProcess()])
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
git = git_client(tmp_path)
|
||||||
|
|
||||||
|
await git.push(workspace, "agent/new", set_upstream=True)
|
||||||
|
await git.push(workspace, "agent/existing")
|
||||||
|
|
||||||
|
assert recorder.commands == [
|
||||||
|
("git", "push", "--set-upstream", "origin", "agent/new"),
|
||||||
|
("git", "push", "origin", "HEAD:agent/existing"),
|
||||||
|
]
|
||||||
|
assert all(call[1]["env"]["GIT_TERMINAL_PROMPT"] == "0" for call in recorder.calls)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_git_translates_process_start_failure(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
install_recorder(monkeypatch, [OSError("git executable missing")])
|
||||||
|
|
||||||
|
with pytest.raises(GitError, match="Could not run git rev-parse") as error:
|
||||||
|
await git_client(tmp_path).current_sha(tmp_path / "repo")
|
||||||
|
|
||||||
|
assert isinstance(error.value.__cause__, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_git_translates_nonzero_exit_and_stderr(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(stderr=b"fatal: invalid diff\n", returncode=2)],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(GitError, match="git diff failed: fatal: invalid diff"):
|
||||||
|
await git_client(tmp_path).diff_check(tmp_path / "repo")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_commit_failure_identifies_commit_operation(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(stderr=b"nothing to commit\n", returncode=1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(GitError, match="git commit failed: nothing to commit"):
|
||||||
|
await git_client(tmp_path).commit(tmp_path / "repo", "agent: change")
|
||||||
+334
-12
@@ -1,22 +1,344 @@
|
|||||||
import json
|
import json
|
||||||
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import respx
|
import pytest
|
||||||
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo
|
||||||
|
|
||||||
|
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@asynccontextmanager
|
||||||
async def test_updates_issue_comment_by_id() -> None:
|
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]:
|
||||||
route = respx.patch(
|
client = Gitea(
|
||||||
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
|
"https://gitea.example/",
|
||||||
).mock(return_value=httpx.Response(200, json={"id": 17}))
|
"secret",
|
||||||
client = GiteaClient("https://gitea.example", "secret")
|
retries=retries,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await client.update_comment("org", "repo", 17, "updated status")
|
yield client
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
assert route.called
|
|
||||||
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
|
async def test_sends_authenticated_json_request_contract() -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(200, json={"id": 17})
|
||||||
|
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
assert await client.update_comment("org", "repo", 17, "updated status")
|
||||||
|
|
||||||
|
assert len(requests) == 1
|
||||||
|
request = requests[0]
|
||||||
|
assert request.method == "PATCH"
|
||||||
|
assert request.url == httpx.URL(
|
||||||
|
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
|
||||||
|
)
|
||||||
|
assert request.headers["authorization"] == "token secret"
|
||||||
|
assert request.headers["accept"] == "application/json"
|
||||||
|
assert request.headers["content-type"] == "application/json"
|
||||||
|
assert json.loads(request.content) == {"body": "updated status"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("permission", "expected"),
|
||||||
|
[("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)],
|
||||||
|
)
|
||||||
|
async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission"
|
||||||
|
return httpx.Response(200, json={"permission": permission})
|
||||||
|
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
assert await client.has_write_permission("org", "repo", "alice") is expected
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maps_issue_and_pull_request_responses() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
match request.url.path:
|
||||||
|
case "/api/v1/repos/org/repo":
|
||||||
|
return httpx.Response(200, json={"default_branch": "trunk"})
|
||||||
|
case "/api/v1/repos/org/repo/issues/12":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"title": "Issue title", "body": None, "state": "open"},
|
||||||
|
)
|
||||||
|
case "/api/v1/repos/org/repo/pulls/8":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"title": "Pull title",
|
||||||
|
"body": None,
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"base": {"ref": "trunk"},
|
||||||
|
"head": {
|
||||||
|
"ref": "feature",
|
||||||
|
"sha": "abc123",
|
||||||
|
"repo": {"owner": {"login": "fork-owner"}, "name": "fork"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise AssertionError(f"unexpected request: {request.url}")
|
||||||
|
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
branch = await client.default_branch("org", "repo")
|
||||||
|
issue = await client.issue("org", "repo", 12)
|
||||||
|
pull = await client.pull_request("org", "repo", 8)
|
||||||
|
|
||||||
|
assert branch == "trunk"
|
||||||
|
assert issue == IssueInfo(number=12, title="Issue title", body="", state="open")
|
||||||
|
assert pull == PullRequestInfo(
|
||||||
|
number=8,
|
||||||
|
title="Pull title",
|
||||||
|
body="",
|
||||||
|
state="open",
|
||||||
|
merged=False,
|
||||||
|
base_branch="trunk",
|
||||||
|
head_branch="feature",
|
||||||
|
head_sha="abc123",
|
||||||
|
head_owner="fork-owner",
|
||||||
|
head_repo="fork",
|
||||||
|
)
|
||||||
|
assert pull.is_open
|
||||||
|
|
||||||
|
|
||||||
|
async def test_creates_comment_and_pull_request_with_expected_payloads() -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
if request.url.path.endswith("/issues/4/comments"):
|
||||||
|
return httpx.Response(201, json={"id": "23"})
|
||||||
|
if request.method == "POST" and request.url.path.endswith("/pulls"):
|
||||||
|
return httpx.Response(201, json={"number": 9})
|
||||||
|
if request.method == "GET" and request.url.path.endswith("/pulls/9"):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"title": "Implement it",
|
||||||
|
"body": "Details",
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"base": {"ref": "main"},
|
||||||
|
"head": {
|
||||||
|
"ref": "agent/work",
|
||||||
|
"sha": "def456",
|
||||||
|
"repo": {"owner": {"login": "org"}, "name": "repo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||||
|
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
comment_id = await client.create_comment("org", "repo", 4, "Working")
|
||||||
|
pull = await client.create_pull_request(
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
title="Implement it",
|
||||||
|
body="Details",
|
||||||
|
head="agent/work",
|
||||||
|
base="main",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert comment_id == 23
|
||||||
|
assert pull.number == 9
|
||||||
|
assert [(request.method, request.url.path) for request in requests] == [
|
||||||
|
("POST", "/api/v1/repos/org/repo/issues/4/comments"),
|
||||||
|
("POST", "/api/v1/repos/org/repo/pulls"),
|
||||||
|
("GET", "/api/v1/repos/org/repo/pulls/9"),
|
||||||
|
]
|
||||||
|
assert json.loads(requests[0].content) == {"body": "Working"}
|
||||||
|
assert json.loads(requests[1].content) == {
|
||||||
|
"title": "Implement it",
|
||||||
|
"body": "Details",
|
||||||
|
"head": "agent/work",
|
||||||
|
"base": "main",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_maps_allowed_not_found_responses_without_retry() -> None:
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
updated = await client.update_comment("org", "repo", 99, "missing")
|
||||||
|
comments = await client.review_comments("org", "repo", 7, 3)
|
||||||
|
|
||||||
|
assert not updated
|
||||||
|
assert comments == []
|
||||||
|
assert paths == [
|
||||||
|
"/api/v1/repos/org/repo/issues/comments/99",
|
||||||
|
"/api/v1/repos/org/repo/pulls/7/reviews/3/comments",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])])
|
||||||
|
async def test_pagination_stops_only_after_a_short_page(
|
||||||
|
first_page_size: int, expected_pages: list[int]
|
||||||
|
) -> None:
|
||||||
|
pages: list[int] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments"
|
||||||
|
assert request.url.params["limit"] == "50"
|
||||||
|
page = int(request.url.params["page"])
|
||||||
|
pages.append(page)
|
||||||
|
size = first_page_size if page == 1 else 1
|
||||||
|
offset = 0 if page == 1 else 50
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json=[
|
||||||
|
{
|
||||||
|
"id": offset + index + 1,
|
||||||
|
"user": {"login": f"user-{offset + index + 1}"},
|
||||||
|
"body": None,
|
||||||
|
"created_at": None,
|
||||||
|
}
|
||||||
|
for index in range(size)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
comments = await client.issue_comments("org", "repo", 6)
|
||||||
|
|
||||||
|
expected_count = first_page_size + (1 if first_page_size == 50 else 0)
|
||||||
|
assert pages == expected_pages
|
||||||
|
assert len(comments) == expected_count
|
||||||
|
if comments:
|
||||||
|
assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="")
|
||||||
|
assert comments[-1].id == expected_count
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
|
||||||
|
async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
attempts = 0
|
||||||
|
sleeps: list[int] = []
|
||||||
|
|
||||||
|
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
return httpx.Response(status)
|
||||||
|
|
||||||
|
async def sleep(delay: int) -> None:
|
||||||
|
sleeps.append(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
with pytest.raises(
|
||||||
|
GiteaError,
|
||||||
|
match=rf"Gitea returned {status} for GET /repos/org/repo",
|
||||||
|
):
|
||||||
|
await client.default_branch("org", "repo")
|
||||||
|
|
||||||
|
assert attempts == 1
|
||||||
|
assert sleeps == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
|
||||||
|
async def test_retryable_status_recovers_after_backoff(
|
||||||
|
status: int, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
attempts = 0
|
||||||
|
sleeps: list[int] = []
|
||||||
|
|
||||||
|
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
return httpx.Response(status)
|
||||||
|
return httpx.Response(200, json={"default_branch": "main"})
|
||||||
|
|
||||||
|
async def sleep(delay: int) -> None:
|
||||||
|
sleeps.append(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
assert await client.default_branch("org", "repo") == "main"
|
||||||
|
|
||||||
|
assert attempts == 2
|
||||||
|
assert sleeps == [1]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_retryable_status_exhaustion_uses_exponential_backoff(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
attempts = 0
|
||||||
|
sleeps: list[int] = []
|
||||||
|
|
||||||
|
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
return httpx.Response(503)
|
||||||
|
|
||||||
|
async def sleep(delay: int) -> None:
|
||||||
|
sleeps.append(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
with pytest.raises(
|
||||||
|
GiteaError,
|
||||||
|
match="Gitea remained unavailable for GET /repos/org/repo",
|
||||||
|
):
|
||||||
|
await client.default_branch("org", "repo")
|
||||||
|
|
||||||
|
assert attempts == 3
|
||||||
|
assert sleeps == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
attempts = 0
|
||||||
|
sleeps: list[int] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts < 3:
|
||||||
|
raise httpx.ConnectError("connection refused", request=request)
|
||||||
|
return httpx.Response(200, json={"default_branch": "main"})
|
||||||
|
|
||||||
|
async def sleep(delay: int) -> None:
|
||||||
|
sleeps.append(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||||
|
async with gitea_client(handler) as client:
|
||||||
|
assert await client.default_branch("org", "repo") == "main"
|
||||||
|
|
||||||
|
assert attempts == 3
|
||||||
|
assert sleeps == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transport_failure_exhaustion_preserves_cause(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
attempts = 0
|
||||||
|
sleeps: list[int] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
raise httpx.ConnectError("connection refused", request=request)
|
||||||
|
|
||||||
|
async def sleep(delay: int) -> None:
|
||||||
|
sleeps.append(delay)
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||||
|
async with gitea_client(handler, retries=2) as client:
|
||||||
|
with pytest.raises(
|
||||||
|
GiteaError,
|
||||||
|
match="Gitea request failed: GET /repos/org/repo",
|
||||||
|
) as raised:
|
||||||
|
await client.default_branch("org", "repo")
|
||||||
|
|
||||||
|
assert isinstance(raised.value.__cause__, httpx.ConnectError)
|
||||||
|
assert attempts == 2
|
||||||
|
assert sleeps == [1]
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient, Response
|
||||||
|
|
||||||
|
from agentci.health import router
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def __init__(self, result: bool | Exception) -> None:
|
||||||
|
self.result = result
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def ready(self) -> bool:
|
||||||
|
self.calls += 1
|
||||||
|
if isinstance(self.result, Exception):
|
||||||
|
raise self.result
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
def application(provider: Provider | None = None) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
if provider is not None:
|
||||||
|
app.state.runtime = SimpleNamespace(opencode=provider)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def get(app: FastAPI, path: str) -> Response:
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app, raise_app_exceptions=False),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
return await client.get(path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_liveness_does_not_depend_on_runtime_providers() -> None:
|
||||||
|
response = await get(application(), "/health/live")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "live"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("provider_ready", "status_code", "payload"),
|
||||||
|
[
|
||||||
|
(True, 200, {"status": "ready"}),
|
||||||
|
(
|
||||||
|
False,
|
||||||
|
503,
|
||||||
|
{
|
||||||
|
"status": "not-ready",
|
||||||
|
"reason": "opencode provider is not connected",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_readiness_reflects_provider_state(
|
||||||
|
provider_ready: bool, status_code: int, payload: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
provider = Provider(provider_ready)
|
||||||
|
|
||||||
|
response = await get(application(provider), "/health/ready")
|
||||||
|
|
||||||
|
assert response.status_code == status_code
|
||||||
|
assert response.json() == payload
|
||||||
|
assert provider.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_readiness_provider_error_is_server_failure() -> None:
|
||||||
|
provider = Provider(RuntimeError("provider check failed"))
|
||||||
|
|
||||||
|
response = await get(application(provider), "/health/ready")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert provider.calls == 1
|
||||||
@@ -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"]
|
|
||||||
@@ -1,12 +1,154 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
|
||||||
def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None:
|
|
||||||
root = Path(__file__).parents[1]
|
|
||||||
script = (root / "install-scripts" / "dotnet").read_text()
|
|
||||||
|
|
||||||
assert "export DOTNET_ROOT=" in script
|
def executable(path: Path, content: str) -> None:
|
||||||
assert "/tmp/agentci-dotnet" not in script
|
path.write_text(content)
|
||||||
assert "DEV_TOOLS_DIR/runtime/dotnet" in script
|
path.chmod(0o755)
|
||||||
assert "NUGET_PACKAGES" in script
|
|
||||||
assert 'export HOME="$DOTNET_CLI_HOME"' in script
|
|
||||||
|
def test_python_installer_configures_uv_and_stable_python_links(tmp_path: Path) -> None:
|
||||||
|
fake_bin = tmp_path / "fake-bin"
|
||||||
|
fake_bin.mkdir()
|
||||||
|
tools = tmp_path / "tools"
|
||||||
|
uv_log = tmp_path / "uv.log"
|
||||||
|
executable(
|
||||||
|
fake_bin / "uv",
|
||||||
|
"""#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
{
|
||||||
|
printf 'UV_NO_CONFIG=%s\n' "$UV_NO_CONFIG"
|
||||||
|
printf 'UV_PYTHON_INSTALL_DIR=%s\n' "$UV_PYTHON_INSTALL_DIR"
|
||||||
|
printf 'UV_PYTHON_BIN_DIR=%s\n' "$UV_PYTHON_BIN_DIR"
|
||||||
|
printf 'UV_PYTHON_INSTALL_BIN=%s\n' "$UV_PYTHON_INSTALL_BIN"
|
||||||
|
printf 'args=%s %s %s\n' "$1" "$2" "$3"
|
||||||
|
} > "$FAKE_UV_LOG"
|
||||||
|
minor=$(printf '%s' "$3" | cut -d. -f1,2)
|
||||||
|
mkdir -p "$UV_PYTHON_INSTALL_DIR" "$UV_PYTHON_BIN_DIR"
|
||||||
|
cat > "$UV_PYTHON_BIN_DIR/python$minor" <<'PYTHON'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' 'Python fake'
|
||||||
|
PYTHON
|
||||||
|
chmod 0755 "$UV_PYTHON_BIN_DIR/python$minor"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"PATH": f"{fake_bin}:{os.environ['PATH']}",
|
||||||
|
"DEV_TOOLS_DIR": str(tools),
|
||||||
|
"PYTHON_VERSION": "3.13.7",
|
||||||
|
"FAKE_UV_LOG": str(uv_log),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["/bin/sh", str(ROOT / "install-scripts" / "python")],
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert result.stdout == "Python fake\n"
|
||||||
|
assert uv_log.read_text().splitlines() == [
|
||||||
|
"UV_NO_CONFIG=1",
|
||||||
|
f"UV_PYTHON_INSTALL_DIR={tools / 'python'}",
|
||||||
|
f"UV_PYTHON_BIN_DIR={tools / 'bin'}",
|
||||||
|
"UV_PYTHON_INSTALL_BIN=1",
|
||||||
|
"args=python install 3.13.7",
|
||||||
|
]
|
||||||
|
assert (tools / "bin" / "python").readlink() == Path("python3.13")
|
||||||
|
assert (tools / "bin" / "python3").readlink() == Path("python3.13")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dotnet_installer_builds_persistent_runtime_wrapper_without_network(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fake_bin = tmp_path / "fake-bin"
|
||||||
|
fake_bin.mkdir()
|
||||||
|
tools = tmp_path / "tools"
|
||||||
|
installer_log = tmp_path / "installer.log"
|
||||||
|
dotnet_log = tmp_path / "dotnet.log"
|
||||||
|
executable(
|
||||||
|
fake_bin / "python3",
|
||||||
|
"""#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
[ "$1" = - ]
|
||||||
|
cat > "$2" <<'INSTALLER'
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf '%s\n' "$@" > "$FAKE_INSTALLER_LOG"
|
||||||
|
install_dir=
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--install-dir) install_dir=$2; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
mkdir -p "$install_dir"
|
||||||
|
cat > "$install_dir/dotnet" <<'DOTNET'
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf '%s|%s|%s|%s|%s|%s\n' \
|
||||||
|
"$*" "$DOTNET_ROOT" "$DOTNET_CLI_HOME" "$NUGET_PACKAGES" \
|
||||||
|
"$NUGET_HTTP_CACHE_PATH" "$HOME" >> "$FAKE_DOTNET_LOG"
|
||||||
|
DOTNET
|
||||||
|
chmod 0755 "$install_dir/dotnet"
|
||||||
|
INSTALLER
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"PATH": f"{fake_bin}:{os.environ['PATH']}",
|
||||||
|
"DEV_TOOLS_DIR": str(tools),
|
||||||
|
"DOTNET_CHANNEL": "10.0",
|
||||||
|
"FAKE_INSTALLER_LOG": str(installer_log),
|
||||||
|
"FAKE_DOTNET_LOG": str(dotnet_log),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["/bin/sh", str(ROOT / "install-scripts" / "dotnet")],
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert installer_log.read_text().splitlines() == [
|
||||||
|
"--channel",
|
||||||
|
"10.0",
|
||||||
|
"--install-dir",
|
||||||
|
str(tools / "dotnet"),
|
||||||
|
"--no-path",
|
||||||
|
]
|
||||||
|
wrapper = tools / "bin" / "dotnet"
|
||||||
|
assert os.access(wrapper, os.X_OK)
|
||||||
|
|
||||||
|
wrapped = subprocess.run(
|
||||||
|
[wrapper, "--version"],
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert wrapped.returncode == 0, wrapped.stderr
|
||||||
|
calls = dotnet_log.read_text().splitlines()
|
||||||
|
assert calls[0].split("|")[0] == "--info"
|
||||||
|
assert calls[1].split("|") == [
|
||||||
|
"--version",
|
||||||
|
str(tools / "dotnet"),
|
||||||
|
str(tools / "runtime" / "dotnet" / "home"),
|
||||||
|
str(tools / "runtime" / "dotnet" / "nuget" / "packages"),
|
||||||
|
str(tools / "runtime" / "dotnet" / "nuget" / "http-cache"),
|
||||||
|
str(tools / "runtime" / "dotnet" / "home"),
|
||||||
|
]
|
||||||
|
assert (tools / "runtime" / "dotnet" / "home").is_dir()
|
||||||
|
assert (tools / "runtime" / "dotnet" / "nuget" / "packages").is_dir()
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine.events import (
|
||||||
|
JobProgress,
|
||||||
|
RuntimeSessionLinked,
|
||||||
|
WorkflowCreated,
|
||||||
|
WorkflowLinked,
|
||||||
|
)
|
||||||
|
from agentci.engine.model import Workflow, WorkflowKind
|
||||||
|
from agentci.engine.run import JobRun
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRepository:
|
||||||
|
def __init__(self, *, fail_event_ids: set[str] | None = None) -> None:
|
||||||
|
self.events: list[tuple[str, object]] = []
|
||||||
|
self.fail_event_ids = fail_event_ids or set()
|
||||||
|
|
||||||
|
async def apply(self, event_id: str, event: object) -> None:
|
||||||
|
self.events.append((event_id, event))
|
||||||
|
if event_id in self.fail_event_ids:
|
||||||
|
raise RuntimeError("write failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_run_emits_each_report_payload_with_monotonic_ids() -> None:
|
||||||
|
repository = RecordingRepository()
|
||||||
|
run = JobRun(repository, "job-1", 10) # type: ignore[arg-type]
|
||||||
|
workflow = Workflow(
|
||||||
|
id="workflow-1",
|
||||||
|
kind=WorkflowKind.PLAN,
|
||||||
|
repo_owner="alice",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=3,
|
||||||
|
workspace_path=Path("/work/repo"),
|
||||||
|
base_sha="abc",
|
||||||
|
)
|
||||||
|
|
||||||
|
await run.stage("cloning")
|
||||||
|
await run.create_workflow(workflow, "planning")
|
||||||
|
await run.link_workflow("workflow-2", "discussing")
|
||||||
|
await run.link_session("session-1")
|
||||||
|
|
||||||
|
assert repository.events == [
|
||||||
|
("task:10:report:1", JobProgress(job_id="job-1", stage="cloning")),
|
||||||
|
(
|
||||||
|
"task:10:report:2",
|
||||||
|
WorkflowCreated(job_id="job-1", workflow=workflow, stage="planning"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"task:10:report:3",
|
||||||
|
WorkflowLinked(job_id="job-1", workflow_id="workflow-2", stage="discussing"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"task:10:report:4",
|
||||||
|
RuntimeSessionLinked(job_id="job-1", session_id="session-1"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failed_report_consumes_only_its_run_sequence_number() -> None:
|
||||||
|
repository = RecordingRepository(fail_event_ids={"task:10:report:1"})
|
||||||
|
first = JobRun(repository, "job-1", 10) # type: ignore[arg-type]
|
||||||
|
second = JobRun(repository, "job-2", 20) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="write failed"):
|
||||||
|
await first.stage("cloning")
|
||||||
|
await second.stage("fixing")
|
||||||
|
await first.link_session("session-1")
|
||||||
|
|
||||||
|
assert [event_id for event_id, _ in repository.events] == [
|
||||||
|
"task:10:report:1",
|
||||||
|
"task:20:report:1",
|
||||||
|
"task:10:report:2",
|
||||||
|
]
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_files_are_at_most_250_lines() -> None:
|
|
||||||
root = Path(__file__).parents[1]
|
|
||||||
files = [*root.glob("src/**/*.py"), *root.glob("tests/**/*.py")]
|
|
||||||
oversized = {
|
|
||||||
str(path.relative_to(root)): len(path.read_text().splitlines())
|
|
||||||
for path in files
|
|
||||||
if len(path.read_text().splitlines()) > 250
|
|
||||||
}
|
|
||||||
assert oversized == {}
|
|
||||||
|
|
||||||
+98
-2
@@ -1,10 +1,106 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from agentci.logging import configure_logging
|
import pytest
|
||||||
|
|
||||||
|
from agentci.logging import JsonFormatter, configure_logging
|
||||||
|
|
||||||
|
|
||||||
def test_suppresses_http_client_request_logs() -> None:
|
@pytest.fixture(autouse=True)
|
||||||
|
def restore_logging_state() -> Iterator[None]:
|
||||||
|
root = logging.getLogger()
|
||||||
|
original_handlers = root.handlers[:]
|
||||||
|
original_level = root.level
|
||||||
|
client_levels = {name: logging.getLogger(name).level for name in ("httpx", "httpcore")}
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
root.handlers[:] = original_handlers
|
||||||
|
root.setLevel(original_level)
|
||||||
|
for name, level in client_levels.items():
|
||||||
|
logging.getLogger(name).setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
def record(*, message: str = "processed job") -> logging.LogRecord:
|
||||||
|
return logging.LogRecord(
|
||||||
|
name="agentci.test",
|
||||||
|
level=logging.INFO,
|
||||||
|
pathname=__file__,
|
||||||
|
lineno=1,
|
||||||
|
msg=message,
|
||||||
|
args=(),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter_emits_stable_structured_contract() -> None:
|
||||||
|
value = record()
|
||||||
|
value.__dict__.update(
|
||||||
|
operation="worker.execute",
|
||||||
|
job_id="job-1",
|
||||||
|
duration_ms=0,
|
||||||
|
unapproved_secret="not-for-output",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(JsonFormatter().format(value))
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
"timestamp": payload["timestamp"],
|
||||||
|
"level": "INFO",
|
||||||
|
"logger": "agentci.test",
|
||||||
|
"message": "processed job",
|
||||||
|
"operation": "worker.execute",
|
||||||
|
"job_id": "job-1",
|
||||||
|
"duration_ms": 0,
|
||||||
|
}
|
||||||
|
timestamp = datetime.fromisoformat(payload["timestamp"])
|
||||||
|
assert timestamp.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter_includes_exception_traceback() -> None:
|
||||||
|
try:
|
||||||
|
raise RuntimeError("provider unavailable")
|
||||||
|
except RuntimeError:
|
||||||
|
value = record(message="request failed")
|
||||||
|
value.exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
payload = json.loads(JsonFormatter().format(value))
|
||||||
|
|
||||||
|
assert payload["message"] == "request failed"
|
||||||
|
assert "RuntimeError: provider unavailable" in payload["exception"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter_survives_unserializable_context() -> None:
|
||||||
|
value = record()
|
||||||
|
value.__dict__["operation"] = object()
|
||||||
|
|
||||||
|
payload = json.loads(JsonFormatter().format(value))
|
||||||
|
|
||||||
|
assert payload["level"] == "INFO"
|
||||||
|
assert payload["logger"] == "agentci.test"
|
||||||
|
assert payload["message"] == "Log record could not be serialized"
|
||||||
|
assert "TypeError" in payload["exception"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_configuration_replaces_handlers_without_duplicate_output(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
configure_logging()
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
assert root.level == logging.INFO
|
||||||
|
assert len(root.handlers) == 1
|
||||||
|
assert isinstance(root.handlers[0].formatter, JsonFormatter)
|
||||||
assert logging.getLogger("httpx").level == logging.WARNING
|
assert logging.getLogger("httpx").level == logging.WARNING
|
||||||
assert logging.getLogger("httpcore").level == logging.WARNING
|
assert logging.getLogger("httpcore").level == logging.WARNING
|
||||||
|
|
||||||
|
logging.getLogger("agentci.contract").info(
|
||||||
|
"configured", extra={"operation": "logging.configure"}
|
||||||
|
)
|
||||||
|
lines = capsys.readouterr().err.splitlines()
|
||||||
|
assert len(lines) == 1
|
||||||
|
assert json.loads(lines[0])["operation"] == "logging.configure"
|
||||||
|
|||||||
+294
-15
@@ -5,8 +5,8 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
from agentci.opencode import OpenCode, OpenCodeError
|
||||||
from agentci.domain.models import AgentResult
|
from agentci.workflows.model import AgentResult
|
||||||
|
|
||||||
API_DOCUMENT = {
|
API_DOCUMENT = {
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -42,9 +42,9 @@ class FakeCodeGraph:
|
|||||||
self.prepared.append(workspace)
|
self.prepared.append(workspace)
|
||||||
|
|
||||||
|
|
||||||
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCodeClient:
|
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCode:
|
||||||
selected_codegraph = codegraph or FakeCodeGraph()
|
selected_codegraph = codegraph or FakeCodeGraph()
|
||||||
return OpenCodeClient(
|
return OpenCode(
|
||||||
base_url="http://opencode:4096",
|
base_url="http://opencode:4096",
|
||||||
username="opencode",
|
username="opencode",
|
||||||
password="server-secret",
|
password="server-secret",
|
||||||
@@ -74,20 +74,154 @@ async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Pa
|
|||||||
await value.close()
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_ready_rejects_missing_provider(tmp_path: Path) -> None:
|
async def test_concurrent_readiness_checks_share_one_probe(tmp_path: Path) -> None:
|
||||||
|
health_started = asyncio.Event()
|
||||||
|
release_health = asyncio.Event()
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
if request.url.path == "/global/health":
|
if request.url.path == "/global/health":
|
||||||
|
health_started.set()
|
||||||
|
await release_health.wait()
|
||||||
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
|
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
|
||||||
if request.url.path == "/doc":
|
if request.url.path == "/doc":
|
||||||
return httpx.Response(200, json=API_DOCUMENT)
|
return httpx.Response(200, json=API_DOCUMENT)
|
||||||
return httpx.Response(200, json={**PROVIDERS, "connected": ["anthropic"]})
|
return httpx.Response(200, json=PROVIDERS)
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
first = asyncio.create_task(value.ready())
|
||||||
|
await health_started.wait()
|
||||||
|
second = asyncio.create_task(value.ready())
|
||||||
|
release_health.set()
|
||||||
|
|
||||||
|
assert await asyncio.gather(first, second) == [True, True]
|
||||||
|
assert paths == ["/global/health", "/doc", "/provider"]
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("health", "document", "providers", "expected_paths"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"healthy": False, "version": "1.18.4"},
|
||||||
|
API_DOCUMENT,
|
||||||
|
PROVIDERS,
|
||||||
|
["/global/health"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"healthy": True, "version": "2.0.0"},
|
||||||
|
API_DOCUMENT,
|
||||||
|
PROVIDERS,
|
||||||
|
["/global/health"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"healthy": True, "version": "1.18.4"},
|
||||||
|
{"paths": {}},
|
||||||
|
PROVIDERS,
|
||||||
|
["/global/health", "/doc"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"healthy": True, "version": "1.18.4"},
|
||||||
|
API_DOCUMENT,
|
||||||
|
{**PROVIDERS, "connected": ["anthropic"]},
|
||||||
|
["/global/health", "/doc", "/provider"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"healthy": True, "version": "1.18.4"},
|
||||||
|
API_DOCUMENT,
|
||||||
|
{
|
||||||
|
"connected": ["openai"],
|
||||||
|
"all": [
|
||||||
|
{
|
||||||
|
"id": "openai",
|
||||||
|
"models": {
|
||||||
|
"model": {
|
||||||
|
"status": "active",
|
||||||
|
"capabilities": {"toolcall": False},
|
||||||
|
"variants": {"high": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
["/global/health", "/doc", "/provider"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_ready_rejects_incomplete_runtime_matrix(
|
||||||
|
tmp_path: Path,
|
||||||
|
health: object,
|
||||||
|
document: object,
|
||||||
|
providers: object,
|
||||||
|
expected_paths: list[str],
|
||||||
|
) -> None:
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
|
if request.url.path == "/global/health":
|
||||||
|
return httpx.Response(200, json=health)
|
||||||
|
if request.url.path == "/doc":
|
||||||
|
return httpx.Response(200, json=document)
|
||||||
|
return httpx.Response(200, json=providers)
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
assert not await value.ready()
|
||||||
|
assert paths == expected_paths
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("response_kind", ["http_error", "invalid_json"])
|
||||||
|
async def test_ready_converts_probe_errors_to_not_ready(tmp_path: Path, response_kind: str) -> None:
|
||||||
|
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
if response_kind == "http_error":
|
||||||
|
return httpx.Response(503)
|
||||||
|
return httpx.Response(200, content=b"not-json")
|
||||||
|
|
||||||
value = client(tmp_path, handler)
|
value = client(tmp_path, handler)
|
||||||
assert not await value.ready()
|
assert not await value.ready()
|
||||||
await value.close()
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
|
@pytest.mark.parametrize(
|
||||||
|
("response_kind", "message"),
|
||||||
|
[
|
||||||
|
("invalid_json", "OpenCode request failed: POST /session"),
|
||||||
|
("array", "OpenCode returned an invalid response for POST /session"),
|
||||||
|
("http_error", "OpenCode request failed: POST /session: gateway detail"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_create_session_reports_malformed_and_error_responses(
|
||||||
|
tmp_path: Path, response_kind: str, message: str
|
||||||
|
) -> None:
|
||||||
|
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
if response_kind == "invalid_json":
|
||||||
|
return httpx.Response(200, content=b"not-json")
|
||||||
|
if response_kind == "array":
|
||||||
|
return httpx.Response(200, json=[])
|
||||||
|
return httpx.Response(502, text="gateway detail")
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
with pytest.raises(OpenCodeError, match=message):
|
||||||
|
await value.create_session(tmp_path, "agent_result.json")
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [{}, {"id": ""}, {"id": 42}])
|
||||||
|
async def test_create_session_requires_nonempty_string_id(
|
||||||
|
tmp_path: Path, payload: dict[str, object]
|
||||||
|
) -> None:
|
||||||
|
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
with pytest.raises(OpenCodeError, match="did not return a session ID"):
|
||||||
|
await value.create_session(tmp_path, "agent_result.json")
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_creates_and_resumes_structured_session_in_workspace(tmp_path: Path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
workspace.mkdir()
|
workspace.mkdir()
|
||||||
codegraph = FakeCodeGraph()
|
codegraph = FakeCodeGraph()
|
||||||
@@ -117,7 +251,9 @@ async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
value = client(tmp_path, handler, codegraph)
|
value = client(tmp_path, handler, codegraph)
|
||||||
session_id, result = await value.start(
|
session_id = await value.create_session(workspace, "agent_result.json")
|
||||||
|
result = await value.resume(
|
||||||
|
session_id=session_id,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
prompt="implement",
|
prompt="implement",
|
||||||
model="openai/model",
|
model="openai/model",
|
||||||
@@ -145,9 +281,7 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
|
|||||||
body = json.loads(request.content)
|
body = json.loads(request.content)
|
||||||
prompts.append(body["parts"][0]["text"])
|
prompts.append(body["parts"][0]["text"])
|
||||||
if len(prompts) == 1:
|
if len(prompts) == 1:
|
||||||
return httpx.Response(
|
return httpx.Response(200, json={"info": {"error": {"name": "StructuredOutputError"}}})
|
||||||
200, json={"info": {"error": {"name": "StructuredOutputError"}}}
|
|
||||||
)
|
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={
|
||||||
@@ -173,15 +307,160 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path)
|
|||||||
await value.close()
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_structured_validation_retry_exhaustion_reports_final_error(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
prompts: list[str] = []
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
|
if request.url.path.endswith("/abort"):
|
||||||
|
return httpx.Response(200, json=True)
|
||||||
|
body = json.loads(request.content)
|
||||||
|
prompts.append(body["parts"][0]["text"])
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"info": {"structured": {"summary_markdown": "", "tests": "invalid"}},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
with pytest.raises(
|
||||||
|
OpenCodeError,
|
||||||
|
match="structured result failed validation",
|
||||||
|
):
|
||||||
|
await value.resume(
|
||||||
|
session_id="ses_existing",
|
||||||
|
workspace=workspace,
|
||||||
|
prompt="continue",
|
||||||
|
model="openai/model",
|
||||||
|
variant=None,
|
||||||
|
schema_name="agent_result.json",
|
||||||
|
result_type=AgentResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(prompts) == 2
|
||||||
|
assert prompts[0] == "continue"
|
||||||
|
assert "without repeating repository work" in prompts[1]
|
||||||
|
assert paths[-1] == "/session/ses_existing/abort"
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cleanup_failure_does_not_mask_turn_error(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
|
if request.url.path.endswith("/abort"):
|
||||||
|
return httpx.Response(500, text="abort failed")
|
||||||
|
return httpx.Response(200, json={"unexpected": True})
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
with pytest.raises(
|
||||||
|
OpenCodeError,
|
||||||
|
match="OpenCode response did not include assistant metadata",
|
||||||
|
):
|
||||||
|
await value.resume(
|
||||||
|
session_id="ses_existing",
|
||||||
|
workspace=workspace,
|
||||||
|
prompt="continue",
|
||||||
|
model="openai/model",
|
||||||
|
variant=None,
|
||||||
|
schema_name="agent_result.json",
|
||||||
|
result_type=AgentResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert paths == [
|
||||||
|
"/session/ses_existing/message",
|
||||||
|
"/session/ses_existing/abort",
|
||||||
|
]
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_aborts_an_active_session(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
message_started = asyncio.Event()
|
||||||
|
release_message = asyncio.Event()
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
|
if request.url.path.endswith("/abort"):
|
||||||
|
assert request.headers["x-opencode-directory"] == str(workspace.resolve())
|
||||||
|
return httpx.Response(200, json=True)
|
||||||
|
message_started.set()
|
||||||
|
await release_message.wait()
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"info": {"structured": {"summary_markdown": "finished", "tests": []}},
|
||||||
|
"parts": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
turn = asyncio.create_task(
|
||||||
|
value.resume(
|
||||||
|
session_id="ses_active",
|
||||||
|
workspace=workspace,
|
||||||
|
prompt="continue",
|
||||||
|
model="openai/model",
|
||||||
|
variant=None,
|
||||||
|
schema_name="agent_result.json",
|
||||||
|
result_type=AgentResult,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await message_started.wait()
|
||||||
|
|
||||||
|
await value.close()
|
||||||
|
release_message.set()
|
||||||
|
result = await turn
|
||||||
|
|
||||||
|
assert result.summary_markdown == "finished"
|
||||||
|
assert paths == ["/session/ses_active/message", "/session/ses_active/abort"]
|
||||||
|
assert value.client.is_closed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_continues_after_active_session_abort_failure(tmp_path: Path) -> None:
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
paths.append(request.url.path)
|
||||||
|
if "/failed/" in request.url.path:
|
||||||
|
return httpx.Response(503)
|
||||||
|
return httpx.Response(200, json=True)
|
||||||
|
|
||||||
|
value = client(tmp_path, handler)
|
||||||
|
value._active_sessions.update(
|
||||||
|
{
|
||||||
|
"failed": tmp_path / "first",
|
||||||
|
"succeeds": tmp_path / "second",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
assert paths == ["/session/failed/abort", "/session/succeeds/abort"]
|
||||||
|
assert value.client.is_closed
|
||||||
|
|
||||||
|
|
||||||
async def test_aborts_timed_out_session(tmp_path: Path) -> None:
|
async def test_aborts_timed_out_session(tmp_path: Path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
workspace.mkdir()
|
workspace.mkdir()
|
||||||
aborted = False
|
abort_count = 0
|
||||||
|
|
||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
nonlocal aborted
|
nonlocal abort_count
|
||||||
if request.url.path.endswith("/abort"):
|
if request.url.path.endswith("/abort"):
|
||||||
aborted = True
|
abort_count += 1
|
||||||
return httpx.Response(200, json=True)
|
return httpx.Response(200, json=True)
|
||||||
raise httpx.ReadTimeout("slow", request=request)
|
raise httpx.ReadTimeout("slow", request=request)
|
||||||
|
|
||||||
@@ -197,7 +476,7 @@ async def test_aborts_timed_out_session(tmp_path: Path) -> None:
|
|||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert aborted
|
assert abort_count == 1
|
||||||
await value.close()
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ from pathlib import Path
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
from agentci.opencode import OpenCode, OpenCodeError
|
||||||
|
|
||||||
|
|
||||||
def client(tmp_path: Path, status: int) -> OpenCodeClient:
|
def client(tmp_path: Path, status: int) -> OpenCode:
|
||||||
return OpenCodeClient(
|
return OpenCode(
|
||||||
base_url="http://opencode:4096",
|
base_url="http://opencode:4096",
|
||||||
username="opencode",
|
username="opencode",
|
||||||
password="secret",
|
password="secret",
|
||||||
@@ -32,3 +32,50 @@ async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -
|
|||||||
with pytest.raises(OpenCodeError):
|
with pytest.raises(OpenCodeError):
|
||||||
await value.abort("session", tmp_path)
|
await value.abort("session", tmp_path)
|
||||||
await value.close()
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_sends_authenticated_workspace_request(tmp_path: Path) -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(204)
|
||||||
|
|
||||||
|
value = OpenCode(
|
||||||
|
base_url="http://opencode:4096/",
|
||||||
|
username="opencode",
|
||||||
|
password="secret",
|
||||||
|
schemas_dir=tmp_path,
|
||||||
|
health_directory=tmp_path,
|
||||||
|
required_models=(),
|
||||||
|
timeout_seconds=60,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
await value.abort("ses_123", tmp_path)
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
assert len(requests) == 1
|
||||||
|
request = requests[0]
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert request.url == httpx.URL("http://opencode:4096/session/ses_123/abort")
|
||||||
|
assert request.headers["authorization"].startswith("Basic ")
|
||||||
|
assert request.headers["x-opencode-directory"] == str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_best_effort_abort_suppresses_transport_failure(tmp_path: Path) -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("connection refused", request=request)
|
||||||
|
|
||||||
|
value = OpenCode(
|
||||||
|
base_url="http://opencode:4096",
|
||||||
|
username="opencode",
|
||||||
|
password="secret",
|
||||||
|
schemas_dir=tmp_path,
|
||||||
|
health_directory=tmp_path,
|
||||||
|
required_models=(),
|
||||||
|
timeout_seconds=60,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
await value.abort("session", tmp_path, best_effort=True)
|
||||||
|
await value.close()
|
||||||
|
|||||||
@@ -1,10 +1,81 @@
|
|||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(line: str) -> int:
|
||||||
|
return len(line) - len(line.lstrip())
|
||||||
|
|
||||||
|
|
||||||
|
def _service(name: str) -> list[str]:
|
||||||
|
lines = (ROOT / "compose.yaml").read_text().splitlines()
|
||||||
|
marker = f" {name}:"
|
||||||
|
start = lines.index(marker) + 1
|
||||||
|
end = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index in range(start, len(lines))
|
||||||
|
if lines[index].strip() and _indent(lines[index]) <= 2
|
||||||
|
),
|
||||||
|
len(lines),
|
||||||
|
)
|
||||||
|
return lines[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _section(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
|
||||||
|
marker = f"{' ' * indent}{name}:"
|
||||||
|
start = lines.index(marker) + 1
|
||||||
|
end = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index in range(start, len(lines))
|
||||||
|
if lines[index].strip() and _indent(lines[index]) <= indent
|
||||||
|
),
|
||||||
|
len(lines),
|
||||||
|
)
|
||||||
|
return lines[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _value(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] == '"':
|
||||||
|
return str(json.loads(value))
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] == "'":
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(lines: list[str], name: str, *, indent: int = 4) -> dict[str, str]:
|
||||||
|
entries = _section(lines, name, indent=indent)
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for line in entries:
|
||||||
|
if _indent(line) != indent + 2 or line.lstrip().startswith("-"):
|
||||||
|
continue
|
||||||
|
key, separator, value = line.strip().partition(":")
|
||||||
|
if separator:
|
||||||
|
result[key] = _value(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence(lines: list[str], name: str, *, indent: int = 4) -> list[str]:
|
||||||
|
entries = _section(lines, name, indent=indent)
|
||||||
|
prefix = f"{' ' * (indent + 2)}- "
|
||||||
|
return [_value(line.removeprefix(prefix)) for line in entries if line.startswith(prefix)]
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(lines: list[str], name: str, *, indent: int = 4) -> str:
|
||||||
|
prefix = f"{' ' * indent}{name}:"
|
||||||
|
line = next(line for line in lines if line.startswith(prefix))
|
||||||
|
return _value(line.removeprefix(prefix))
|
||||||
|
|
||||||
|
|
||||||
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
||||||
root = Path(__file__).parents[1]
|
config = json.loads((ROOT / "opencode" / "opencode.json").read_text())
|
||||||
config = json.loads((root / "opencode" / "opencode.json").read_text())
|
|
||||||
|
|
||||||
assert "permission" not in config
|
assert "permission" not in config
|
||||||
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
|
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
|
||||||
@@ -22,22 +93,100 @@ def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
|||||||
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
|
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
|
||||||
|
|
||||||
|
|
||||||
def test_compose_removes_codex_sandbox_exceptions() -> None:
|
def test_compose_services_have_expected_runtime_contract() -> None:
|
||||||
root = Path(__file__).parents[1]
|
agentci = _service("agentci")
|
||||||
compose = (root / "compose.yaml").read_text()
|
opencode = _service("opencode")
|
||||||
dockerfile = (root / "Dockerfile").read_text()
|
agentci_environment = _mapping(agentci, "environment")
|
||||||
|
opencode_environment = _mapping(opencode, "environment")
|
||||||
|
|
||||||
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"):
|
assert agentci_environment["AGENTCI_OPENCODE_URL"] == "http://opencode:4096"
|
||||||
|
assert agentci_environment["AGENTCI_EXPLORE_VARIANT"] == ("${AGENTCI_EXPLORE_VARIANT:-low}")
|
||||||
|
assert agentci_environment["AGENTCI_RESEARCH_VARIANT"] == ("${AGENTCI_RESEARCH_VARIANT:-high}")
|
||||||
|
assert opencode_environment["HOME"] == "/etc/opencode/home"
|
||||||
|
assert opencode_environment["OPENCODE_DISABLE_EXTERNAL_SKILLS"] == "1"
|
||||||
|
assert opencode_environment["OPENCODE_ENABLE_EXA"] == "1"
|
||||||
|
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in opencode_environment
|
||||||
|
|
||||||
|
assert _sequence(agentci, "volumes") == [
|
||||||
|
"agentci_data:/var/lib/agentci",
|
||||||
|
"./install-scripts:/etc/agentci/install-scripts:ro",
|
||||||
|
]
|
||||||
|
assert _sequence(opencode, "volumes") == [
|
||||||
|
"agentci_data:/var/lib/agentci",
|
||||||
|
"opencode_home:/var/lib/opencode",
|
||||||
|
]
|
||||||
|
assert _sequence(agentci, "tmpfs") == ["/run/agentci:mode=1777"]
|
||||||
|
assert _sequence(opencode, "tmpfs") == ["/run/agentci:mode=1777"]
|
||||||
|
assert json.loads(_scalar(opencode, "command")) == [
|
||||||
|
"opencode",
|
||||||
|
"serve",
|
||||||
|
"--hostname",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"4096",
|
||||||
|
]
|
||||||
|
|
||||||
|
build = _section(agentci, "build")
|
||||||
|
assert _mapping(build, "args", indent=6)["AGENTCI_OPENCODE_VERSION"] == (
|
||||||
|
"${AGENTCI_OPENCODE_VERSION:-^1}"
|
||||||
|
)
|
||||||
|
assert all("no_cache" not in line for line in build)
|
||||||
|
|
||||||
|
dependency = _section(_section(agentci, "depends_on"), "opencode", indent=6)
|
||||||
|
assert _mapping([" dependency:", *dependency], "dependency", indent=6) == {
|
||||||
|
"condition": "service_healthy"
|
||||||
|
}
|
||||||
|
healthcheck = _mapping(opencode, "healthcheck")
|
||||||
|
health_command = json.loads(healthcheck["test"])
|
||||||
|
assert health_command[0] == "CMD-SHELL"
|
||||||
|
assert "/global/health" in health_command[1]
|
||||||
|
assert "OPENCODE_SERVER_PASSWORD_FILE" in health_command[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_has_no_sandbox_security_exceptions() -> None:
|
||||||
|
compose = (ROOT / "compose.yaml").read_text()
|
||||||
|
|
||||||
|
for forbidden in (
|
||||||
|
"cap_add:",
|
||||||
|
"security_opt:",
|
||||||
|
"privileged:",
|
||||||
|
"seccomp=unconfined",
|
||||||
|
"apparmor=unconfined",
|
||||||
|
"bubblewrap",
|
||||||
|
):
|
||||||
assert forbidden not in compose
|
assert forbidden not in compose
|
||||||
assert "no_cache" not in compose
|
|
||||||
assert "opencode_home:/var/lib/opencode" in compose
|
|
||||||
assert "HOME: /etc/opencode/home" in compose
|
def test_container_pins_opencode_major_version_contract() -> None:
|
||||||
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
|
lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()]
|
||||||
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose
|
build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")}
|
||||||
assert 'OPENCODE_ENABLE_EXA: "1"' in compose
|
|
||||||
assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose
|
assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments
|
||||||
assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose
|
assert any(
|
||||||
assert compose.count("/run/agentci:mode=1777") == 2
|
line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines
|
||||||
assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose
|
)
|
||||||
assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile
|
|
||||||
assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile
|
|
||||||
|
def test_compose_document_validates_when_compose_cli_is_available() -> None:
|
||||||
|
docker = shutil.which("docker")
|
||||||
|
if docker is None:
|
||||||
|
pytest.skip("Docker Compose CLI is not installed")
|
||||||
|
probe = subprocess.run(
|
||||||
|
[docker, "compose", "version"],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if probe.returncode:
|
||||||
|
pytest.skip("Docker Compose provider is not available")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[docker, "compose", "config", "--quiet"],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from agentci.adapters.opencode_support import api_contract_ready, models_ready
|
import pytest
|
||||||
|
|
||||||
|
from agentci.opencode import api_contract_ready, models_ready
|
||||||
|
|
||||||
|
|
||||||
def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
||||||
@@ -17,7 +19,124 @@ def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
|||||||
assert not api_contract_ready(valid)
|
assert not api_contract_ready(valid)
|
||||||
|
|
||||||
|
|
||||||
def test_model_readiness_requires_tools_and_configured_variant() -> None:
|
@pytest.mark.parametrize(
|
||||||
|
"document",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
{"paths": []},
|
||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"/global/health": None,
|
||||||
|
"/provider": {"get": {}},
|
||||||
|
"/session": {"post": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"/global/health": {"get": {}},
|
||||||
|
"/provider": {"get": {}},
|
||||||
|
"/session": {"post": {}},
|
||||||
|
7: {"post": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_contract_rejects_malformed_documents(document: object) -> None:
|
||||||
|
assert not api_contract_ready(document)
|
||||||
|
|
||||||
|
|
||||||
|
def provider_payload(
|
||||||
|
*,
|
||||||
|
connected: object = None,
|
||||||
|
status: str = "active",
|
||||||
|
toolcall: object = True,
|
||||||
|
variants: object = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
selected_connected = ["openai"] if connected is None else connected
|
||||||
|
selected_variants = {"high": {}} if variants is None else variants
|
||||||
|
return {
|
||||||
|
"connected": selected_connected,
|
||||||
|
"all": [
|
||||||
|
{
|
||||||
|
"id": "openai",
|
||||||
|
"models": {
|
||||||
|
"model": {
|
||||||
|
"status": status,
|
||||||
|
"capabilities": {"toolcall": toolcall},
|
||||||
|
"variants": selected_variants,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("payload", "requirement", "expected"),
|
||||||
|
[
|
||||||
|
(provider_payload(), ("openai", "model", "high"), True),
|
||||||
|
(provider_payload(), ("openai", "model", None), True),
|
||||||
|
(provider_payload(connected=("anthropic",)), ("openai", "model", "high"), False),
|
||||||
|
(provider_payload(status="deprecated"), ("openai", "model", "high"), False),
|
||||||
|
(provider_payload(toolcall=False), ("openai", "model", "high"), False),
|
||||||
|
(provider_payload(variants={}), ("openai", "model", "high"), False),
|
||||||
|
(provider_payload(), ("openai", "missing", "high"), False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_model_readiness_matrix(
|
||||||
|
payload: object, requirement: tuple[str, str, str | None], expected: bool
|
||||||
|
) -> None:
|
||||||
|
assert models_ready(payload, {requirement}) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
{"connected": None, "all": []},
|
||||||
|
{"connected": ["openai"], "all": None},
|
||||||
|
{"connected": ["openai"], "all": [{"id": [], "models": {}}]},
|
||||||
|
{
|
||||||
|
"connected": ["openai"],
|
||||||
|
"all": [
|
||||||
|
{
|
||||||
|
"id": "openai",
|
||||||
|
"models": {
|
||||||
|
"model": {
|
||||||
|
"status": "active",
|
||||||
|
"capabilities": None,
|
||||||
|
"variants": {"high": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"connected": ["openai"],
|
||||||
|
"all": [
|
||||||
|
{
|
||||||
|
"id": "openai",
|
||||||
|
"models": {
|
||||||
|
"model": {
|
||||||
|
"status": "active",
|
||||||
|
"capabilities": {"toolcall": True},
|
||||||
|
"variants": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_model_readiness_rejects_malformed_payloads(payload: object) -> None:
|
||||||
|
assert not models_ready(payload, {("openai", "model", "high")})
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_readiness_requires_all_configured_models() -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"connected": ["openai"],
|
"connected": ["openai"],
|
||||||
"all": [
|
"all": [
|
||||||
@@ -34,7 +153,10 @@ def test_model_readiness_requires_tools_and_configured_variant() -> None:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
assert models_ready(payload, {("openai", "model", "high")})
|
assert not models_ready(
|
||||||
assert not models_ready(payload, {("openai", "model", "missing")})
|
payload,
|
||||||
payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False
|
{
|
||||||
assert not models_ready(payload, {("openai", "model", "high")})
|
("openai", "model", "high"),
|
||||||
|
("openai", "other", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import agentci.prompts
|
||||||
|
from agentci.opencode import load_schema
|
||||||
|
from agentci.prompts import PromptLibrary
|
||||||
|
from agentci.workflows.model import (
|
||||||
|
AgentResult,
|
||||||
|
DiscussionReply,
|
||||||
|
PlanArtifact,
|
||||||
|
ReviewFinding,
|
||||||
|
ReviewReport,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROMPTS = {
|
||||||
|
"plan_initial": {"context", "request"},
|
||||||
|
"plan_review": {"context", "artifact"},
|
||||||
|
"plan_revision": {"artifact", "review"},
|
||||||
|
"discuss": {"artifact", "message"},
|
||||||
|
"plan_iterate": {"context", "artifact", "review", "message"},
|
||||||
|
"implement_initial": {
|
||||||
|
"context",
|
||||||
|
"artifact",
|
||||||
|
"request",
|
||||||
|
"development_environment",
|
||||||
|
},
|
||||||
|
"implementation_review": {"issue_context", "artifact", "pull_context"},
|
||||||
|
"implementation_revision": {"review", "development_environment"},
|
||||||
|
"implementation_iterate": {
|
||||||
|
"context",
|
||||||
|
"review",
|
||||||
|
"message",
|
||||||
|
"development_environment",
|
||||||
|
},
|
||||||
|
"fix": {"context", "message", "development_environment"},
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMAS: dict[str, tuple[type[BaseModel], set[str]]] = {
|
||||||
|
"plan.json": (PlanArtifact, {"plan_markdown"}),
|
||||||
|
"discussion.json": (DiscussionReply, {"markdown"}),
|
||||||
|
"agent_result.json": (AgentResult, {"summary_markdown", "tests"}),
|
||||||
|
"review.json": (ReviewReport, {"summary", "findings"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_directory() -> Path:
|
||||||
|
module_file = agentci.prompts.__file__
|
||||||
|
assert module_file is not None
|
||||||
|
return Path(module_file).parent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("name", "identifiers"), PROMPTS.items())
|
||||||
|
def test_referenced_prompt_loads_and_renders(name: str, identifiers: set[str]) -> None:
|
||||||
|
directory = prompt_directory()
|
||||||
|
source = (directory / f"{name}.md").read_text()
|
||||||
|
values = {identifier: f"<{identifier}-value>" for identifier in identifiers}
|
||||||
|
|
||||||
|
assert set(Template(source).get_identifiers()) == identifiers
|
||||||
|
rendered = PromptLibrary(directory).render(name, **values)
|
||||||
|
for value in values.values():
|
||||||
|
assert value in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_resource_set_matches_workflow_references() -> None:
|
||||||
|
names = {path.stem for path in prompt_directory().glob("*.md")}
|
||||||
|
|
||||||
|
assert names == set(PROMPTS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_render_rejects_missing_template_value() -> None:
|
||||||
|
with pytest.raises(KeyError, match="request"):
|
||||||
|
PromptLibrary(prompt_directory()).render("plan_initial", context="context")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("name", "contract"), SCHEMAS.items())
|
||||||
|
def test_referenced_schema_matches_result_model(
|
||||||
|
name: str, contract: tuple[type[BaseModel], set[str]]
|
||||||
|
) -> None:
|
||||||
|
model, required = contract
|
||||||
|
schema = load_schema(prompt_directory() / "schemas", name)
|
||||||
|
|
||||||
|
assert schema["type"] == "object"
|
||||||
|
assert schema["additionalProperties"] is False
|
||||||
|
assert set(schema["properties"]) == set(model.model_fields)
|
||||||
|
assert set(schema["required"]) == required
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_finding_schema_matches_model_and_severity_values() -> None:
|
||||||
|
schema = load_schema(prompt_directory() / "schemas", "review.json")
|
||||||
|
finding = schema["properties"]["findings"]["items"]
|
||||||
|
|
||||||
|
assert set(finding["properties"]) == set(ReviewFinding.model_fields)
|
||||||
|
assert set(finding["required"]) == set(ReviewFinding.model_fields)
|
||||||
|
assert finding["properties"]["severity"]["enum"] == [
|
||||||
|
"blocking",
|
||||||
|
"major",
|
||||||
|
"minor",
|
||||||
|
]
|
||||||
|
assert finding["additionalProperties"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_resource_set_matches_workflow_references() -> None:
|
||||||
|
schema_directory = prompt_directory() / "schemas"
|
||||||
|
names = {path.name for path in schema_directory.glob("*.json")}
|
||||||
|
|
||||||
|
assert names == set(SCHEMAS)
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
from dataclasses import FrozenInstanceError, replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine.events import (
|
||||||
|
CommandReceived,
|
||||||
|
CommentLinked,
|
||||||
|
JobCompleted,
|
||||||
|
JobFailed,
|
||||||
|
JobProgress,
|
||||||
|
JobRejected,
|
||||||
|
JobStarted,
|
||||||
|
PermissionDenied,
|
||||||
|
PermissionGranted,
|
||||||
|
RuntimeSessionLinked,
|
||||||
|
ServiceRestarted,
|
||||||
|
WorkflowCreated,
|
||||||
|
WorkflowLinked,
|
||||||
|
)
|
||||||
|
from agentci.engine.model import (
|
||||||
|
Job,
|
||||||
|
JobKind,
|
||||||
|
JobStatus,
|
||||||
|
QueueName,
|
||||||
|
TaskKind,
|
||||||
|
TaskRequest,
|
||||||
|
Workflow,
|
||||||
|
WorkflowKind,
|
||||||
|
)
|
||||||
|
from agentci.engine.reducer import InvalidTransition, Transition, reduce_job, render_job_comment
|
||||||
|
|
||||||
|
|
||||||
|
def received(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
|
||||||
|
return reduce_job(
|
||||||
|
None,
|
||||||
|
CommandReceived(
|
||||||
|
job_id="job",
|
||||||
|
delivery_id="delivery",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body=body,
|
||||||
|
target_key="org/repo:pr:8" if pr_number else "org/repo:issue:1",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
pr_number=pr_number,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=4,
|
||||||
|
),
|
||||||
|
).job
|
||||||
|
|
||||||
|
|
||||||
|
def queued(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
|
||||||
|
state = received(body, pr_number=pr_number)
|
||||||
|
return reduce_job(state, PermissionGranted(job_id=state.id)).job
|
||||||
|
|
||||||
|
|
||||||
|
def running(*, workflow_id: str | None = None) -> Job:
|
||||||
|
state = queued()
|
||||||
|
state = reduce_job(state, JobStarted(job_id=state.id)).job
|
||||||
|
return replace(state, workflow_id=workflow_id)
|
||||||
|
|
||||||
|
|
||||||
|
def task_order(transition: Transition) -> list[tuple[TaskKind, QueueName]]:
|
||||||
|
return [(task.kind, task.queue) for task in transition.tasks]
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_received_preserves_input_and_requests_authorization() -> None:
|
||||||
|
transition = reduce_job(
|
||||||
|
None,
|
||||||
|
CommandReceived(
|
||||||
|
job_id="job-7",
|
||||||
|
delivery_id="delivery-7",
|
||||||
|
receive_sequence=7,
|
||||||
|
command_body="/agent fix race",
|
||||||
|
target_key="org/repo:pr:8",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
pr_number=8,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transition == Transition(
|
||||||
|
Job(
|
||||||
|
id="job-7",
|
||||||
|
target_key="org/repo:pr:8",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
pr_number=8,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=4,
|
||||||
|
delivery_id="delivery-7",
|
||||||
|
receive_sequence=7,
|
||||||
|
command_body="/agent fix race",
|
||||||
|
),
|
||||||
|
(TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("body", "pr_number", "expected_kind", "expected_message"),
|
||||||
|
[
|
||||||
|
("/agent plan write tests", None, JobKind.PLAN, "write tests"),
|
||||||
|
("/agent iterate refine", None, JobKind.ITERATE_PLAN, "refine"),
|
||||||
|
("/agent iterate address review", 8, JobKind.ITERATE_IMPLEMENT, "address review"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_permission_granted_queues_the_resolved_command(
|
||||||
|
body: str,
|
||||||
|
pr_number: int | None,
|
||||||
|
expected_kind: JobKind,
|
||||||
|
expected_message: str,
|
||||||
|
) -> None:
|
||||||
|
transition = reduce_job(
|
||||||
|
received(body, pr_number=pr_number),
|
||||||
|
PermissionGranted(job_id="job"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
transition.job.status,
|
||||||
|
transition.job.stage,
|
||||||
|
transition.job.kind,
|
||||||
|
transition.job.message,
|
||||||
|
task_order(transition),
|
||||||
|
) == (
|
||||||
|
JobStatus.QUEUED,
|
||||||
|
"queued",
|
||||||
|
expected_kind,
|
||||||
|
expected_message,
|
||||||
|
[
|
||||||
|
(TaskKind.EXECUTE, QueueName.JOBS),
|
||||||
|
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "expected_error"),
|
||||||
|
[
|
||||||
|
(PermissionDenied(job_id="job"), "repository write permission is required"),
|
||||||
|
(PermissionGranted(job_id="job"), "Unknown agent command"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_permission_rejection_is_terminal_and_reconciled(
|
||||||
|
event: PermissionDenied | PermissionGranted,
|
||||||
|
expected_error: str,
|
||||||
|
) -> None:
|
||||||
|
state = received("/agent nonsense") if isinstance(event, PermissionGranted) else received()
|
||||||
|
transition = reduce_job(state, event)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
transition.job.status,
|
||||||
|
transition.job.stage,
|
||||||
|
expected_error in (transition.job.error or ""),
|
||||||
|
task_order(transition),
|
||||||
|
) == (
|
||||||
|
JobStatus.REJECTED,
|
||||||
|
"rejected",
|
||||||
|
True,
|
||||||
|
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_moves_a_queued_job_to_running_and_reconciles() -> None:
|
||||||
|
transition = reduce_job(queued(), JobStarted(job_id="job"))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
transition.job.status,
|
||||||
|
transition.job.stage,
|
||||||
|
task_order(transition),
|
||||||
|
) == (
|
||||||
|
JobStatus.RUNNING,
|
||||||
|
"starting",
|
||||||
|
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "changes"),
|
||||||
|
[
|
||||||
|
(JobProgress(job_id="job", stage="cloning"), {"stage": "cloning"}),
|
||||||
|
(
|
||||||
|
WorkflowCreated(
|
||||||
|
job_id="job",
|
||||||
|
workflow=Workflow(
|
||||||
|
id="created-workflow",
|
||||||
|
kind=WorkflowKind.PLAN,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
workspace_path=Path("/work/repo"),
|
||||||
|
base_sha="abc",
|
||||||
|
),
|
||||||
|
stage="planning",
|
||||||
|
),
|
||||||
|
{"workflow_id": "created-workflow", "stage": "planning"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkflowLinked(job_id="job", workflow_id="linked-workflow", stage="discussing"),
|
||||||
|
{"workflow_id": "linked-workflow", "stage": "discussing"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RuntimeSessionLinked(job_id="job", session_id="session-1"),
|
||||||
|
{"runtime_session_id": "session-1"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_running_progress_and_links_update_only_reported_fields(
|
||||||
|
event: JobProgress | WorkflowCreated | WorkflowLinked | RuntimeSessionLinked,
|
||||||
|
changes: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
state = running()
|
||||||
|
transition = reduce_job(state, event)
|
||||||
|
|
||||||
|
assert transition == Transition(replace(state, **changes))
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_persists_the_result_and_reconciles() -> None:
|
||||||
|
transition = reduce_job(running(), JobCompleted(job_id="job", comment_body="# Result"))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
transition.job.status,
|
||||||
|
transition.job.stage,
|
||||||
|
transition.job.comment_body,
|
||||||
|
task_order(transition),
|
||||||
|
) == (
|
||||||
|
JobStatus.SUCCEEDED,
|
||||||
|
"completed",
|
||||||
|
"# Result",
|
||||||
|
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event", "workflow_id", "expected_status", "expected_stage", "expected_error", "tasks"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
JobFailed(job_id="job", stage="testing", error="tests failed"),
|
||||||
|
None,
|
||||||
|
JobStatus.FAILED,
|
||||||
|
"testing",
|
||||||
|
"tests failed",
|
||||||
|
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
JobFailed(job_id="job", stage="testing", error="tests failed"),
|
||||||
|
"workflow",
|
||||||
|
JobStatus.FAILED,
|
||||||
|
"testing",
|
||||||
|
"tests failed",
|
||||||
|
[
|
||||||
|
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||||
|
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
JobRejected(job_id="job", reason="no plan exists"),
|
||||||
|
"workflow",
|
||||||
|
JobStatus.REJECTED,
|
||||||
|
"rejected",
|
||||||
|
"no plan exists",
|
||||||
|
[
|
||||||
|
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||||
|
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_running_failure_and_rejection_are_terminal_with_ordered_cleanup(
|
||||||
|
event: JobFailed | JobRejected,
|
||||||
|
workflow_id: str | None,
|
||||||
|
expected_status: JobStatus,
|
||||||
|
expected_stage: str,
|
||||||
|
expected_error: str,
|
||||||
|
tasks: list[tuple[TaskKind, QueueName]],
|
||||||
|
) -> None:
|
||||||
|
transition = reduce_job(running(workflow_id=workflow_id), event)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
transition.job.status,
|
||||||
|
transition.job.stage,
|
||||||
|
transition.job.error,
|
||||||
|
task_order(transition),
|
||||||
|
) == (expected_status, expected_stage, expected_error, tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("workflow_id", "tasks"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
[
|
||||||
|
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
|
||||||
|
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow",
|
||||||
|
[
|
||||||
|
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
|
||||||
|
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
|
||||||
|
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_restart_fails_a_running_job_with_ordered_cleanup(
|
||||||
|
workflow_id: str | None,
|
||||||
|
tasks: list[tuple[TaskKind, QueueName]],
|
||||||
|
) -> None:
|
||||||
|
transition = reduce_job(running(workflow_id=workflow_id), ServiceRestarted(job_id="job"))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
transition.job.status,
|
||||||
|
transition.job.stage,
|
||||||
|
transition.job.error,
|
||||||
|
task_order(transition),
|
||||||
|
) == (
|
||||||
|
JobStatus.FAILED,
|
||||||
|
"interrupted",
|
||||||
|
"Service restarted during an active OpenCode turn",
|
||||||
|
tasks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_is_a_noop_after_a_job_is_terminal() -> None:
|
||||||
|
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
|
||||||
|
|
||||||
|
assert reduce_job(completed, ServiceRestarted(job_id="job")) == Transition(completed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_link_is_allowed_after_a_job_is_terminal() -> None:
|
||||||
|
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
|
||||||
|
|
||||||
|
assert reduce_job(completed, CommentLinked(job_id="job", comment_id=9)).job == replace(
|
||||||
|
completed, accepted_comment_id=9
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("state", "event"),
|
||||||
|
[
|
||||||
|
(received(), JobStarted(job_id="job")),
|
||||||
|
(queued(), PermissionGranted(job_id="job")),
|
||||||
|
(running(), PermissionGranted(job_id="job")),
|
||||||
|
(
|
||||||
|
reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job,
|
||||||
|
JobStarted(job_id="job"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["received", "queued", "running", "terminal"],
|
||||||
|
)
|
||||||
|
def test_events_invalid_for_the_current_status_are_rejected(
|
||||||
|
state: Job,
|
||||||
|
event: JobStarted | PermissionGranted,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(InvalidTransition, match="invalid while job"):
|
||||||
|
reduce_job(state, event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_command_received_can_create_state() -> None:
|
||||||
|
with pytest.raises(InvalidTransition, match="Only CommandReceived"):
|
||||||
|
reduce_job(None, PermissionGranted(job_id="job"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_job_id_must_match_state() -> None:
|
||||||
|
with pytest.raises(InvalidTransition, match="job ID does not match"):
|
||||||
|
reduce_job(received(), PermissionDenied(job_id="another-job"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("state", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
received(),
|
||||||
|
"<!-- agentci:job id=job -->\nAgent job `job` received (`command`; stage: `received`).",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
queued(),
|
||||||
|
"<!-- agentci:job id=job -->\nAgent job `job` queued (`plan`; stage: `queued`).",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
replace(
|
||||||
|
running(),
|
||||||
|
status=JobStatus.SUCCEEDED,
|
||||||
|
stage="completed",
|
||||||
|
comment_body="# Done",
|
||||||
|
),
|
||||||
|
"<!-- agentci:job id=job -->\n# Done",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"),
|
||||||
|
"<!-- agentci:job id=job -->\nAgent job `job` was rejected: not allowed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"),
|
||||||
|
"<!-- agentci:job id=job -->\nAgent job `job` failed during `testing`: failed",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["received", "queued", "succeeded", "rejected", "failed"],
|
||||||
|
)
|
||||||
|
def test_render_comment_describes_each_job_outcome(state: Job, expected: str) -> None:
|
||||||
|
assert render_job_comment(state) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_state_is_immutable() -> None:
|
||||||
|
state = received()
|
||||||
|
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
state.stage = "changed" # type: ignore[misc]
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine import _sqlite
|
||||||
|
from agentci.engine.events import (
|
||||||
|
CommentLinked,
|
||||||
|
JobCompleted,
|
||||||
|
JobStarted,
|
||||||
|
PermissionGranted,
|
||||||
|
WorkflowCreated,
|
||||||
|
)
|
||||||
|
from agentci.engine.model import (
|
||||||
|
IncomingCommand,
|
||||||
|
Job,
|
||||||
|
QueueName,
|
||||||
|
TaskKind,
|
||||||
|
TaskRequest,
|
||||||
|
Workflow,
|
||||||
|
WorkflowKind,
|
||||||
|
WorkflowStatus,
|
||||||
|
)
|
||||||
|
from agentci.engine.reducer import Transition
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingConnection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def __enter__(self) -> "TrackingConnection":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class FailingSetupConnection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.closed = False
|
||||||
|
self.row_factory: object | None = None
|
||||||
|
|
||||||
|
def execute(self, _statement: str) -> None:
|
||||||
|
raise sqlite3.OperationalError("pragma failed")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def repository(tmp_path: Path) -> Repository:
|
||||||
|
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||||
|
await value.initialize()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def command(
|
||||||
|
delivery: str,
|
||||||
|
body: str = "/agent plan",
|
||||||
|
*,
|
||||||
|
issue: int = 3,
|
||||||
|
pr: int | None = None,
|
||||||
|
) -> IncomingCommand:
|
||||||
|
return IncomingCommand(
|
||||||
|
delivery_id=delivery,
|
||||||
|
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||||
|
repo_owner="alice",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=issue,
|
||||||
|
pr_number=pr,
|
||||||
|
requester="alice",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_running_job(repository: Repository, delivery: str, *, issue: int = 3) -> Job:
|
||||||
|
job = (await repository.accept(command(delivery, issue=issue))).job
|
||||||
|
job = (await repository.apply(f"{delivery}:grant", PermissionGranted(job_id=job.id))).job
|
||||||
|
return (await repository.apply(f"{delivery}:start", JobStarted(job_id=job.id))).job
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repository_closes_connections_after_success_and_failure(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
connections = [TrackingConnection(), TrackingConnection()]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_sqlite,
|
||||||
|
"connect",
|
||||||
|
lambda _path: cast(sqlite3.Connection, connections.pop(0)),
|
||||||
|
)
|
||||||
|
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||||
|
successful = cast(TrackingConnection, await repository._run(lambda connection: connection))
|
||||||
|
|
||||||
|
def fail(_connection: sqlite3.Connection) -> None:
|
||||||
|
raise RuntimeError("operation failed")
|
||||||
|
|
||||||
|
failing = connections[0]
|
||||||
|
with pytest.raises(RuntimeError, match="operation failed"):
|
||||||
|
await repository._run(fail)
|
||||||
|
|
||||||
|
assert successful.closed
|
||||||
|
assert failing.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_setup_closes_connection_after_pragma_failure(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
connection = FailingSetupConnection()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_sqlite.sqlite3,
|
||||||
|
"connect",
|
||||||
|
lambda *_args, **_kwargs: cast(sqlite3.Connection, connection),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(sqlite3.OperationalError, match="pragma failed"):
|
||||||
|
_sqlite.connect(tmp_path / "state.sqlite3")
|
||||||
|
|
||||||
|
assert connection.closed
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow(
|
||||||
|
workflow_id: str,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
kind: WorkflowKind = WorkflowKind.PLAN,
|
||||||
|
owner: str = "alice",
|
||||||
|
repo: str = "repo",
|
||||||
|
issue: int = 3,
|
||||||
|
pr: int | None = None,
|
||||||
|
status: WorkflowStatus = WorkflowStatus.ACTIVE,
|
||||||
|
) -> Workflow:
|
||||||
|
return Workflow(
|
||||||
|
id=workflow_id,
|
||||||
|
kind=kind,
|
||||||
|
repo_owner=owner,
|
||||||
|
repo_name=repo,
|
||||||
|
issue_number=issue,
|
||||||
|
pr_number=pr,
|
||||||
|
workspace_path=workspace / workflow_id,
|
||||||
|
base_sha=f"base-{workflow_id}",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_workflow(
|
||||||
|
repository: Repository,
|
||||||
|
workspace: Path,
|
||||||
|
*,
|
||||||
|
delivery: str,
|
||||||
|
workflow_id: str,
|
||||||
|
status: WorkflowStatus = WorkflowStatus.ACTIVE,
|
||||||
|
) -> tuple[Job, Workflow]:
|
||||||
|
job = await create_running_job(repository, delivery)
|
||||||
|
workflow = build_workflow(workflow_id, workspace, status=status)
|
||||||
|
result = await repository.apply(
|
||||||
|
f"{workflow_id}:created",
|
||||||
|
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
|
||||||
|
)
|
||||||
|
return result.job, workflow
|
||||||
|
|
||||||
|
|
||||||
|
def insert_workflow(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
workflow: Workflow,
|
||||||
|
created_at: str,
|
||||||
|
) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO workflows(
|
||||||
|
id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch,
|
||||||
|
workspace_path, primary_session_id, reviewer_session_id, artifact, review_json,
|
||||||
|
status, runtime, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
workflow.id,
|
||||||
|
workflow.kind.value,
|
||||||
|
workflow.repo_owner,
|
||||||
|
workflow.repo_name,
|
||||||
|
workflow.issue_number,
|
||||||
|
workflow.pr_number,
|
||||||
|
workflow.base_sha,
|
||||||
|
workflow.branch,
|
||||||
|
str(workflow.workspace_path),
|
||||||
|
workflow.primary_session_id,
|
||||||
|
workflow.reviewer_session_id,
|
||||||
|
workflow.artifact,
|
||||||
|
workflow.review_json,
|
||||||
|
workflow.status.value,
|
||||||
|
workflow.runtime,
|
||||||
|
created_at,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_receive_is_idempotent_without_consuming_sequence(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
first = await repository.accept(command("delivery-1"))
|
||||||
|
duplicate = await repository.accept(command("delivery-1"))
|
||||||
|
second = await repository.accept(command("delivery-2"))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
first.duplicate,
|
||||||
|
duplicate.duplicate,
|
||||||
|
duplicate.job.id,
|
||||||
|
second.job.receive_sequence,
|
||||||
|
) == (False, True, first.job.id, first.job.receive_sequence + 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repository_stamps_start_and_completion_once(
|
||||||
|
repository: Repository,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||||
|
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||||
|
job = (await repository.accept(command("delivery-1"))).job
|
||||||
|
job = (await repository.apply("grant", PermissionGranted(job_id=job.id))).job
|
||||||
|
clock["now"] = "2026-02-01T00:01:00+00:00"
|
||||||
|
job = (await repository.apply("start", JobStarted(job_id=job.id))).job
|
||||||
|
clock["now"] = "2026-02-01T00:02:00+00:00"
|
||||||
|
job = (await repository.apply("complete", JobCompleted(job_id=job.id, comment_body="done"))).job
|
||||||
|
clock["now"] = "2026-02-01T00:03:00+00:00"
|
||||||
|
await repository.apply("comment", CommentLinked(job_id=job.id, comment_id=99))
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
timestamps = connection.execute(
|
||||||
|
"SELECT started_at, finished_at FROM jobs WHERE id=?", (job.id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert timestamps == (
|
||||||
|
"2026-02-01T00:01:00+00:00",
|
||||||
|
"2026-02-01T00:02:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_failure(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
job = await create_running_job(repository, "delivery-1")
|
||||||
|
workflow = build_workflow("workflow-rollback", tmp_path)
|
||||||
|
original_insert_tasks = _sqlite.insert_tasks
|
||||||
|
|
||||||
|
def fail_after_task_write(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
event_id: str,
|
||||||
|
transition: Transition,
|
||||||
|
timestamp: str,
|
||||||
|
) -> None:
|
||||||
|
forced_task = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||||
|
original_insert_tasks(
|
||||||
|
connection,
|
||||||
|
event_id,
|
||||||
|
replace(transition, tasks=(forced_task,)),
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
raise RuntimeError("injected task persistence failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(_sqlite, "insert_tasks", fail_after_task_write)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="injected task persistence failure"):
|
||||||
|
await repository.apply(
|
||||||
|
"workflow-rollback:created",
|
||||||
|
WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"),
|
||||||
|
)
|
||||||
|
|
||||||
|
persisted_job = await repository.get_job(job.id)
|
||||||
|
persisted_workflow = await repository.get_workflow(workflow.id)
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
event_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM job_events WHERE event_id=?",
|
||||||
|
("workflow-rollback:created",),
|
||||||
|
).fetchone()
|
||||||
|
task_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM listener_tasks WHERE source_event_id=?",
|
||||||
|
("workflow-rollback:created",),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert (persisted_job, persisted_workflow, event_count, task_count) == (
|
||||||
|
job,
|
||||||
|
None,
|
||||||
|
(0,),
|
||||||
|
(0,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_event_application_does_not_duplicate_tasks(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
job = (await repository.accept(command("delivery-1"))).job
|
||||||
|
applied = await repository.apply("permission", PermissionGranted(job_id=job.id))
|
||||||
|
duplicate = await repository.apply("permission", PermissionGranted(job_id=job.id))
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
tasks = connection.execute(
|
||||||
|
"""SELECT ordinal, listener, queue FROM listener_tasks
|
||||||
|
WHERE source_event_id=? ORDER BY ordinal""",
|
||||||
|
("permission",),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert (duplicate.duplicate, duplicate.job, tasks) == (
|
||||||
|
True,
|
||||||
|
applied.job,
|
||||||
|
[(0, "execute", "jobs"), (1, "reconcile_comment", "control")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workflow_creation_and_job_link_commit_together(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
job, workflow = await attach_workflow(
|
||||||
|
repository,
|
||||||
|
tmp_path,
|
||||||
|
delivery="delivery-1",
|
||||||
|
workflow_id="workflow-atomic",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (job.workflow_id, await repository.get_workflow(workflow.id)) == (
|
||||||
|
workflow.id,
|
||||||
|
workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_workflow_updates_the_mutable_snapshot_and_timestamp(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_, workflow = await attach_workflow(
|
||||||
|
repository,
|
||||||
|
tmp_path,
|
||||||
|
delivery="delivery-1",
|
||||||
|
workflow_id="workflow-update",
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
workflow,
|
||||||
|
pr_number=17,
|
||||||
|
branch="agent/updated",
|
||||||
|
primary_session_id="primary",
|
||||||
|
reviewer_session_id="reviewer",
|
||||||
|
artifact="# Updated plan",
|
||||||
|
review_json='{"verdict":"approved"}',
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(_sqlite, "now", lambda: "2026-02-02T00:00:00+00:00")
|
||||||
|
|
||||||
|
await repository.save_workflow(updated)
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
updated_at = connection.execute(
|
||||||
|
"SELECT updated_at FROM workflows WHERE id=?", (workflow.id,)
|
||||||
|
).fetchone()
|
||||||
|
assert (await repository.get_workflow(workflow.id), updated_at) == (
|
||||||
|
updated,
|
||||||
|
("2026-02-02T00:00:00+00:00",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_saving_unknown_workflow_fails(repository: Repository, tmp_path: Path) -> None:
|
||||||
|
workflow = build_workflow("missing", tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="Unknown workflow"):
|
||||||
|
await repository.save_workflow(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fail_job_workflow_fails_only_active_workflows(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
active_job, active = await attach_workflow(
|
||||||
|
repository,
|
||||||
|
tmp_path,
|
||||||
|
delivery="delivery-1",
|
||||||
|
workflow_id="active-workflow",
|
||||||
|
)
|
||||||
|
completed_job, completed = await attach_workflow(
|
||||||
|
repository,
|
||||||
|
tmp_path,
|
||||||
|
delivery="delivery-2",
|
||||||
|
workflow_id="completed-workflow",
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
await repository.fail_job_workflow(active_job.id)
|
||||||
|
await repository.fail_job_workflow(completed_job.id)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
(await repository.get_workflow(active.id)).status, # type: ignore[union-attr]
|
||||||
|
(await repository.get_workflow(completed.id)).status, # type: ignore[union-attr]
|
||||||
|
) == (WorkflowStatus.FAILED, WorkflowStatus.COMPLETED)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_workflow_returns_newest_completed_match(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
older = build_workflow("plan-older", tmp_path, status=WorkflowStatus.COMPLETED)
|
||||||
|
newest = build_workflow("plan-newest", tmp_path, status=WorkflowStatus.COMPLETED)
|
||||||
|
active = build_workflow("plan-active", tmp_path)
|
||||||
|
wrong_issue = build_workflow(
|
||||||
|
"plan-wrong-issue", tmp_path, issue=4, status=WorkflowStatus.COMPLETED
|
||||||
|
)
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, active, "2026-01-03T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, wrong_issue, "2026-01-04T00:00:00+00:00")
|
||||||
|
|
||||||
|
assert await repository.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) == newest
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workflow_for_pr_returns_newest_implementation_match(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
|
||||||
|
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
|
||||||
|
plan = build_workflow("plan-same-pr", tmp_path, pr=17)
|
||||||
|
wrong_repo = build_workflow(
|
||||||
|
"implementation-wrong-repo",
|
||||||
|
tmp_path,
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo="another",
|
||||||
|
pr=17,
|
||||||
|
)
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, plan, "2026-01-03T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, wrong_repo, "2026-01-04T00:00:00+00:00")
|
||||||
|
|
||||||
|
assert await repository.workflow_for_pr("alice", "repo", 17) == newest
|
||||||
|
|
||||||
|
|
||||||
|
async def test_implementation_workflows_are_newest_first_and_require_a_pr(
|
||||||
|
repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17)
|
||||||
|
newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=18)
|
||||||
|
no_pr = build_workflow("implementation-no-pr", tmp_path, kind=WorkflowKind.IMPLEMENT)
|
||||||
|
plan = build_workflow("plan-with-pr", tmp_path, pr=19)
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
insert_workflow(connection, older, "2026-01-01T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, no_pr, "2026-01-03T00:00:00+00:00")
|
||||||
|
insert_workflow(connection, plan, "2026-01-04T00:00:00+00:00")
|
||||||
|
|
||||||
|
assert await repository.implementation_workflows("alice", "repo", 3) == [newest, older]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine.events import PermissionDenied, PermissionGranted
|
||||||
|
from agentci.engine.model import IncomingCommand, QueueName, TaskKind
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def repository(tmp_path: Path) -> Repository:
|
||||||
|
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||||
|
await value.initialize()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
|
||||||
|
return IncomingCommand(
|
||||||
|
delivery_id=delivery,
|
||||||
|
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||||
|
repo_owner="alice",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=issue,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
body="/agent plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_duplicate_accepts_create_one_job_and_event(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
repository.accept(command("delivery-1")),
|
||||||
|
repository.accept(command("delivery-1")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sorted(result.duplicate for result in results) == [False, True]
|
||||||
|
assert len({result.job.id for result in results}) == 1
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
job_count = connection.execute("SELECT COUNT(*) FROM jobs").fetchone()
|
||||||
|
event_count = connection.execute("SELECT COUNT(*) FROM job_events").fetchone()
|
||||||
|
assert job_count == (1,)
|
||||||
|
assert event_count == (1,)
|
||||||
|
|
||||||
|
second = await repository.accept(command("delivery-2"))
|
||||||
|
assert second.job.receive_sequence == results[0].job.receive_sequence + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_received_job_blocks_later_execute_task_for_same_target(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
first = (await repository.accept(command("delivery-1"))).job
|
||||||
|
second = (await repository.accept(command("delivery-2"))).job
|
||||||
|
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
|
||||||
|
|
||||||
|
assert await repository.claim_task(QueueName.JOBS) is None
|
||||||
|
|
||||||
|
await repository.apply("deny-1", PermissionDenied(job_id=first.id))
|
||||||
|
task = await repository.claim_task(QueueName.JOBS)
|
||||||
|
assert task is not None
|
||||||
|
assert task.job_id == second.id
|
||||||
|
assert task.kind is TaskKind.EXECUTE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_received_job_does_not_block_a_different_target(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
await repository.accept(command("delivery-1", issue=3))
|
||||||
|
second = (await repository.accept(command("delivery-2", issue=4))).job
|
||||||
|
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
|
||||||
|
|
||||||
|
task = await repository.claim_task(QueueName.JOBS)
|
||||||
|
assert task is not None
|
||||||
|
assert task.job_id == second.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claims_multiple_eligible_targets_without_duplicates(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
first = (await repository.accept(command("delivery-1", issue=3))).job
|
||||||
|
second = (await repository.accept(command("delivery-2", issue=4))).job
|
||||||
|
await repository.apply("grant-1", PermissionGranted(job_id=first.id))
|
||||||
|
await repository.apply("grant-2", PermissionGranted(job_id=second.id))
|
||||||
|
|
||||||
|
claimed = [
|
||||||
|
await repository.claim_task(QueueName.JOBS),
|
||||||
|
await repository.claim_task(QueueName.JOBS),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id]
|
||||||
|
assert await repository.claim_task(QueueName.JOBS) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_claims_do_not_duplicate_task(repository: Repository) -> None:
|
||||||
|
await repository.accept(command("delivery-1"))
|
||||||
|
|
||||||
|
claims = await asyncio.gather(
|
||||||
|
repository.claim_task(QueueName.CONTROL),
|
||||||
|
repository.claim_task(QueueName.CONTROL),
|
||||||
|
)
|
||||||
|
|
||||||
|
claimed = [task for task in claims if task is not None]
|
||||||
|
assert len(claimed) == 1
|
||||||
|
assert claimed[0].kind is TaskKind.AUTHORIZE
|
||||||
|
assert claimed[0].queue is QueueName.CONTROL
|
||||||
|
assert await repository.claim_task(QueueName.CONTROL) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_event_id_cannot_be_reused_for_another_job(
|
||||||
|
repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
first = (await repository.accept(command("delivery-1", issue=3))).job
|
||||||
|
second = (await repository.accept(command("delivery-2", issue=4))).job
|
||||||
|
await repository.apply("permission", PermissionGranted(job_id=first.id))
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await repository.apply("permission", PermissionGranted(job_id=second.id))
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine.model import JobKind, JobStatus, Workflow, WorkflowKind, WorkflowStatus
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||||
|
PRE_V3_CREATED_AT = "2026-01-01T00:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def create_schema_v2_database(database_path: Path, workspace: Path) -> None:
|
||||||
|
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||||
|
connection.executescript((MIGRATIONS / "001_initial.sql").read_text())
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||||
|
(1, "2025-11-01T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
connection.executescript((MIGRATIONS / "002_opencode_sessions.sql").read_text())
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
|
||||||
|
(2, "2025-12-01T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO deliveries(delivery_id, comment_id, received_at) VALUES (?, ?, ?)",
|
||||||
|
[
|
||||||
|
("delivery-queued", 102, "2025-12-31T23:59:58+00:00"),
|
||||||
|
("delivery-unrelated", 999, "2025-12-31T23:59:59+00:00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"""INSERT INTO workflows(
|
||||||
|
id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch,
|
||||||
|
workspace_path, primary_session_id, reviewer_session_id, artifact, review_json,
|
||||||
|
status, created_at, updated_at, runtime
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"pre-v3-plan-workflow",
|
||||||
|
"plan",
|
||||||
|
"alice",
|
||||||
|
"repo",
|
||||||
|
7,
|
||||||
|
None,
|
||||||
|
"plan-base",
|
||||||
|
"agent/plan",
|
||||||
|
str(workspace / "plan"),
|
||||||
|
"plan-primary",
|
||||||
|
"plan-reviewer",
|
||||||
|
"# Existing plan",
|
||||||
|
'{"verdict":"approved"}',
|
||||||
|
"active",
|
||||||
|
"2025-12-30T00:00:00+00:00",
|
||||||
|
"2025-12-31T00:00:00+00:00",
|
||||||
|
"opencode",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pre-v3-implementation-workflow",
|
||||||
|
"implement",
|
||||||
|
"alice",
|
||||||
|
"repo",
|
||||||
|
8,
|
||||||
|
44,
|
||||||
|
"implementation-base",
|
||||||
|
"agent/implementation",
|
||||||
|
str(workspace / "implementation"),
|
||||||
|
"implementation-primary",
|
||||||
|
"implementation-reviewer",
|
||||||
|
"# Existing implementation",
|
||||||
|
'{"verdict":"changes_requested"}',
|
||||||
|
"completed",
|
||||||
|
"2025-12-29T00:00:00+00:00",
|
||||||
|
"2025-12-31T12:00:00+00:00",
|
||||||
|
"codex",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"""INSERT INTO jobs(
|
||||||
|
id, kind, target_key, repo_owner, repo_name, issue_number, pr_number,
|
||||||
|
requester, message, comment_id, workflow_id, status, stage, error,
|
||||||
|
accepted_comment_id, started_comment_id, created_at, started_at, finished_at,
|
||||||
|
runtime_session_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"pre-v3-job-a-terminal",
|
||||||
|
"iterate_implement",
|
||||||
|
"alice/repo:pr:44",
|
||||||
|
"alice",
|
||||||
|
"repo",
|
||||||
|
8,
|
||||||
|
44,
|
||||||
|
"bob",
|
||||||
|
"address review",
|
||||||
|
101,
|
||||||
|
"pre-v3-implementation-workflow",
|
||||||
|
"succeeded",
|
||||||
|
"completed",
|
||||||
|
None,
|
||||||
|
201,
|
||||||
|
202,
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
"2026-01-01T00:01:00+00:00",
|
||||||
|
"2026-01-01T00:02:00+00:00",
|
||||||
|
"implementation-runtime",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pre-v3-job-b-queued",
|
||||||
|
"iterate_plan",
|
||||||
|
"alice/repo:issue:7",
|
||||||
|
"alice",
|
||||||
|
"repo",
|
||||||
|
7,
|
||||||
|
None,
|
||||||
|
"alice",
|
||||||
|
"refine plan",
|
||||||
|
102,
|
||||||
|
"pre-v3-plan-workflow",
|
||||||
|
"queued",
|
||||||
|
"queued",
|
||||||
|
None,
|
||||||
|
203,
|
||||||
|
204,
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"plan-runtime",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def migrated_pre_v3_repository(tmp_path: Path) -> Repository:
|
||||||
|
database_path = tmp_path / "schema-v2.sqlite3"
|
||||||
|
create_schema_v2_database(database_path, tmp_path / "workspaces")
|
||||||
|
repository = Repository(database_path, MIGRATIONS)
|
||||||
|
await repository.initialize()
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_commands(
|
||||||
|
migrated_pre_v3_repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
with (
|
||||||
|
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||||
|
connection,
|
||||||
|
):
|
||||||
|
migrated = connection.execute(
|
||||||
|
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs "
|
||||||
|
"ORDER BY receive_sequence"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert migrated == [
|
||||||
|
(
|
||||||
|
"pre-v3-job-a-terminal",
|
||||||
|
"legacy:pre-v3-job-a-terminal",
|
||||||
|
1,
|
||||||
|
"/agent iterate address review",
|
||||||
|
),
|
||||||
|
("pre-v3-job-b-queued", "delivery-queued", 2, "/agent iterate refine plan"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schema_v2_migration_preserves_pre_v3_job_fields(
|
||||||
|
migrated_pre_v3_repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
terminal = await migrated_pre_v3_repository.get_job("pre-v3-job-a-terminal")
|
||||||
|
queued = await migrated_pre_v3_repository.get_job("pre-v3-job-b-queued")
|
||||||
|
with (
|
||||||
|
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||||
|
connection,
|
||||||
|
):
|
||||||
|
storage_fields = connection.execute(
|
||||||
|
"""SELECT id, started_comment_id, created_at, started_at, finished_at
|
||||||
|
FROM jobs ORDER BY receive_sequence"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
terminal
|
||||||
|
and (
|
||||||
|
terminal.kind,
|
||||||
|
terminal.target_key,
|
||||||
|
terminal.repo_owner,
|
||||||
|
terminal.repo_name,
|
||||||
|
terminal.issue_number,
|
||||||
|
terminal.pr_number,
|
||||||
|
terminal.requester,
|
||||||
|
terminal.message,
|
||||||
|
terminal.comment_id,
|
||||||
|
terminal.workflow_id,
|
||||||
|
terminal.status,
|
||||||
|
terminal.stage,
|
||||||
|
terminal.error,
|
||||||
|
terminal.runtime_session_id,
|
||||||
|
terminal.accepted_comment_id,
|
||||||
|
terminal.comment_body,
|
||||||
|
),
|
||||||
|
queued
|
||||||
|
and (
|
||||||
|
queued.kind,
|
||||||
|
queued.target_key,
|
||||||
|
queued.requester,
|
||||||
|
queued.message,
|
||||||
|
queued.comment_id,
|
||||||
|
queued.workflow_id,
|
||||||
|
queued.status,
|
||||||
|
queued.stage,
|
||||||
|
queued.runtime_session_id,
|
||||||
|
queued.accepted_comment_id,
|
||||||
|
),
|
||||||
|
storage_fields,
|
||||||
|
) == (
|
||||||
|
(
|
||||||
|
JobKind.ITERATE_IMPLEMENT,
|
||||||
|
"alice/repo:pr:44",
|
||||||
|
"alice",
|
||||||
|
"repo",
|
||||||
|
8,
|
||||||
|
44,
|
||||||
|
"bob",
|
||||||
|
"address review",
|
||||||
|
101,
|
||||||
|
"pre-v3-implementation-workflow",
|
||||||
|
JobStatus.SUCCEEDED,
|
||||||
|
"completed",
|
||||||
|
None,
|
||||||
|
"implementation-runtime",
|
||||||
|
201,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
JobKind.ITERATE_PLAN,
|
||||||
|
"alice/repo:issue:7",
|
||||||
|
"alice",
|
||||||
|
"refine plan",
|
||||||
|
102,
|
||||||
|
"pre-v3-plan-workflow",
|
||||||
|
JobStatus.QUEUED,
|
||||||
|
"queued",
|
||||||
|
"plan-runtime",
|
||||||
|
203,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"pre-v3-job-a-terminal",
|
||||||
|
202,
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
"2026-01-01T00:01:00+00:00",
|
||||||
|
"2026-01-01T00:02:00+00:00",
|
||||||
|
),
|
||||||
|
("pre-v3-job-b-queued", 204, PRE_V3_CREATED_AT, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schema_v2_migration_preserves_pre_v3_workflows(
|
||||||
|
migrated_pre_v3_repository: Repository,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
plan = await migrated_pre_v3_repository.get_workflow("pre-v3-plan-workflow")
|
||||||
|
implementation = await migrated_pre_v3_repository.get_workflow("pre-v3-implementation-workflow")
|
||||||
|
with (
|
||||||
|
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||||
|
connection,
|
||||||
|
):
|
||||||
|
timestamps = connection.execute(
|
||||||
|
"SELECT id, created_at, updated_at FROM workflows ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert ((plan, implementation), timestamps) == (
|
||||||
|
(
|
||||||
|
Workflow(
|
||||||
|
id="pre-v3-plan-workflow",
|
||||||
|
kind=WorkflowKind.PLAN,
|
||||||
|
repo_owner="alice",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
workspace_path=tmp_path / "workspaces" / "plan",
|
||||||
|
base_sha="plan-base",
|
||||||
|
runtime="opencode",
|
||||||
|
branch="agent/plan",
|
||||||
|
primary_session_id="plan-primary",
|
||||||
|
reviewer_session_id="plan-reviewer",
|
||||||
|
artifact="# Existing plan",
|
||||||
|
review_json='{"verdict":"approved"}',
|
||||||
|
status=WorkflowStatus.ACTIVE,
|
||||||
|
),
|
||||||
|
Workflow(
|
||||||
|
id="pre-v3-implementation-workflow",
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo_owner="alice",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=8,
|
||||||
|
pr_number=44,
|
||||||
|
workspace_path=tmp_path / "workspaces" / "implementation",
|
||||||
|
base_sha="implementation-base",
|
||||||
|
runtime="codex",
|
||||||
|
branch="agent/implementation",
|
||||||
|
primary_session_id="implementation-primary",
|
||||||
|
reviewer_session_id="implementation-reviewer",
|
||||||
|
artifact="# Existing implementation",
|
||||||
|
review_json='{"verdict":"changes_requested"}',
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"pre-v3-implementation-workflow",
|
||||||
|
"2025-12-29T00:00:00+00:00",
|
||||||
|
"2025-12-31T12:00:00+00:00",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pre-v3-plan-workflow",
|
||||||
|
"2025-12-30T00:00:00+00:00",
|
||||||
|
"2025-12-31T00:00:00+00:00",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_schema_v2_migration_creates_audit_events_and_only_queued_tasks(
|
||||||
|
migrated_pre_v3_repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
with (
|
||||||
|
closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection,
|
||||||
|
connection,
|
||||||
|
):
|
||||||
|
events = connection.execute(
|
||||||
|
"""SELECT event_id, job_id, event_type, payload_json, created_at
|
||||||
|
FROM job_events ORDER BY job_id"""
|
||||||
|
).fetchall()
|
||||||
|
tasks = connection.execute(
|
||||||
|
"""SELECT job_id, source_event_id, ordinal, listener, queue, status,
|
||||||
|
available_at, created_at
|
||||||
|
FROM listener_tasks ORDER BY ordinal"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert (events, tasks) == (
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"delivery:legacy:pre-v3-job-a-terminal",
|
||||||
|
"pre-v3-job-a-terminal",
|
||||||
|
"legacy",
|
||||||
|
"{}",
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"delivery:delivery-queued",
|
||||||
|
"pre-v3-job-b-queued",
|
||||||
|
"legacy",
|
||||||
|
"{}",
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"pre-v3-job-b-queued",
|
||||||
|
"delivery:delivery-queued",
|
||||||
|
0,
|
||||||
|
"execute",
|
||||||
|
"jobs",
|
||||||
|
"pending",
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pre-v3-job-b-queued",
|
||||||
|
"delivery:delivery-queued",
|
||||||
|
1,
|
||||||
|
"reconcile_comment",
|
||||||
|
"control",
|
||||||
|
"pending",
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
PRE_V3_CREATED_AT,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def durable_snapshot(database_path: Path) -> tuple[object, ...]:
|
||||||
|
with closing(sqlite3.connect(database_path)) as connection, connection:
|
||||||
|
return (
|
||||||
|
connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall(),
|
||||||
|
connection.execute(
|
||||||
|
"SELECT id, delivery_id, receive_sequence, command_body FROM jobs ORDER BY id"
|
||||||
|
).fetchall(),
|
||||||
|
connection.execute(
|
||||||
|
"""SELECT event_id, job_id, event_type, payload_json
|
||||||
|
FROM job_events ORDER BY event_id"""
|
||||||
|
).fetchall(),
|
||||||
|
connection.execute(
|
||||||
|
"""SELECT job_id, source_event_id, ordinal, listener, queue, status
|
||||||
|
FROM listener_tasks ORDER BY id"""
|
||||||
|
).fetchall(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reopening_migrated_schema_v2_database_is_idempotent(
|
||||||
|
migrated_pre_v3_repository: Repository,
|
||||||
|
) -> None:
|
||||||
|
before = durable_snapshot(migrated_pre_v3_repository.database_path)
|
||||||
|
reopened = Repository(migrated_pre_v3_repository.database_path, MIGRATIONS)
|
||||||
|
|
||||||
|
await reopened.initialize()
|
||||||
|
|
||||||
|
assert (before[0], durable_snapshot(reopened.database_path)) == (
|
||||||
|
[(1,), (2,), (3,)],
|
||||||
|
before,
|
||||||
|
)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine import _sqlite
|
||||||
|
from agentci.engine import repository as repository_module
|
||||||
|
from agentci.engine.events import JobStarted, PermissionGranted
|
||||||
|
from agentci.engine.model import IncomingCommand, QueueName, Task, TaskKind
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def repository(tmp_path: Path) -> Repository:
|
||||||
|
value = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||||
|
await value.initialize()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def command(delivery: str, *, issue: int = 3) -> IncomingCommand:
|
||||||
|
return IncomingCommand(
|
||||||
|
delivery_id=delivery,
|
||||||
|
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||||
|
repo_owner="alice",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=issue,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
body="/agent plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def task_storage(repository: Repository, task_id: int) -> tuple[object, ...]:
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""SELECT status, attempts, available_at, error, created_at, started_at, finished_at
|
||||||
|
FROM listener_tasks WHERE id=?""",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps(
|
||||||
|
repository: Repository,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||||
|
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||||
|
job = (await repository.accept(command("delivery-1"))).job
|
||||||
|
clock["now"] = "2026-02-01T00:01:00+00:00"
|
||||||
|
|
||||||
|
task = await repository.claim_task(QueueName.CONTROL)
|
||||||
|
assert task is not None
|
||||||
|
clock["now"] = "2026-02-01T00:02:00+00:00"
|
||||||
|
await repository.complete_task(task.id)
|
||||||
|
|
||||||
|
assert (task, task_storage(repository, task.id)) == (
|
||||||
|
Task(
|
||||||
|
id=task.id,
|
||||||
|
job_id=job.id,
|
||||||
|
source_event_id="delivery:delivery-1",
|
||||||
|
kind=TaskKind.AUTHORIZE,
|
||||||
|
queue=QueueName.CONTROL,
|
||||||
|
attempts=1,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"completed",
|
||||||
|
1,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
None,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
"2026-02-01T00:01:00+00:00",
|
||||||
|
"2026-02-01T00:02:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("attempts", "delay_seconds"), [(1, 2), (9, 256)])
|
||||||
|
async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_due(
|
||||||
|
repository: Repository,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
attempts: int,
|
||||||
|
delay_seconds: int,
|
||||||
|
) -> None:
|
||||||
|
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||||
|
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||||
|
await repository.accept(command("delivery-1"))
|
||||||
|
task = await repository.claim_task(QueueName.CONTROL)
|
||||||
|
assert task is not None
|
||||||
|
with closing(sqlite3.connect(repository.database_path)) as connection, connection:
|
||||||
|
connection.execute("UPDATE listener_tasks SET attempts=? WHERE id=?", (attempts, task.id))
|
||||||
|
|
||||||
|
retry_time = datetime(2026, 2, 1, 1, tzinfo=UTC)
|
||||||
|
available_at = retry_time + timedelta(seconds=delay_seconds)
|
||||||
|
|
||||||
|
class FixedDateTime:
|
||||||
|
@staticmethod
|
||||||
|
def now(_timezone: object) -> datetime:
|
||||||
|
return retry_time
|
||||||
|
|
||||||
|
monkeypatch.setattr(repository_module, "datetime", FixedDateTime)
|
||||||
|
error = "failure: " + "x" * 1100
|
||||||
|
await repository.retry_task(task.id, attempts, error)
|
||||||
|
pending_storage = task_storage(repository, task.id)
|
||||||
|
clock["now"] = (available_at - timedelta(microseconds=1)).isoformat()
|
||||||
|
early_claim = await repository.claim_task(QueueName.CONTROL)
|
||||||
|
clock["now"] = available_at.isoformat()
|
||||||
|
due_claim = await repository.claim_task(QueueName.CONTROL)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
pending_storage,
|
||||||
|
early_claim,
|
||||||
|
due_claim and due_claim.attempts,
|
||||||
|
) == (
|
||||||
|
(
|
||||||
|
"pending",
|
||||||
|
attempts,
|
||||||
|
available_at.isoformat(),
|
||||||
|
error[:1000],
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
attempts + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recovery_requeues_control_and_unstarted_execution_but_fails_started_execution(
|
||||||
|
repository: Repository,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
clock = {"now": "2026-02-01T00:00:00+00:00"}
|
||||||
|
monkeypatch.setattr(_sqlite, "now", lambda: clock["now"])
|
||||||
|
|
||||||
|
await repository.accept(command("delivery-1", issue=1))
|
||||||
|
control_task = await repository.claim_task(QueueName.CONTROL)
|
||||||
|
assert control_task is not None
|
||||||
|
|
||||||
|
queued_job = (await repository.accept(command("delivery-2", issue=2))).job
|
||||||
|
await repository.apply("queued:grant", PermissionGranted(job_id=queued_job.id))
|
||||||
|
queued_execute = await repository.claim_task(QueueName.JOBS)
|
||||||
|
assert queued_execute is not None
|
||||||
|
|
||||||
|
running_job = (await repository.accept(command("delivery-3", issue=3))).job
|
||||||
|
await repository.apply("running:grant", PermissionGranted(job_id=running_job.id))
|
||||||
|
running_execute = await repository.claim_task(QueueName.JOBS)
|
||||||
|
assert running_execute is not None
|
||||||
|
await repository.apply("running:start", JobStarted(job_id=running_job.id))
|
||||||
|
|
||||||
|
clock["now"] = "2026-02-01T01:00:00+00:00"
|
||||||
|
await repository.recover_tasks()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
task_storage(repository, control_task.id),
|
||||||
|
task_storage(repository, queued_execute.id),
|
||||||
|
task_storage(repository, running_execute.id),
|
||||||
|
[job.id for job in await repository.running_jobs()],
|
||||||
|
) == (
|
||||||
|
(
|
||||||
|
"pending",
|
||||||
|
1,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
None,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pending",
|
||||||
|
1,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
None,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"failed",
|
||||||
|
1,
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
"Service restarted after execution began",
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
"2026-02-01T00:00:00+00:00",
|
||||||
|
"2026-02-01T01:00:00+00:00",
|
||||||
|
),
|
||||||
|
[running_job.id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import agentci.runtime as runtime_module
|
||||||
|
from agentci.config import Settings
|
||||||
|
from agentci.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
class ClosingClient:
|
||||||
|
def __init__(self, name: str, events: list[str], error: Exception | None = None) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.events = events
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.events.append(self.name)
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
|
||||||
|
|
||||||
|
def runtime(opencode: object, gitea: object) -> Runtime:
|
||||||
|
return Runtime(
|
||||||
|
settings=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
repository=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
gitea=gitea, # type: ignore[arg-type]
|
||||||
|
git=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
opencode=opencode, # type: ignore[arg-type]
|
||||||
|
worker=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_releases_provider_clients_in_order() -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
value = runtime(ClosingClient("opencode", events), ClosingClient("gitea", events))
|
||||||
|
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
assert events == ["opencode", "gitea"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_still_releases_gitea_when_opencode_close_fails() -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
value = runtime(
|
||||||
|
ClosingClient("opencode", events, RuntimeError("opencode close failed")),
|
||||||
|
ClosingClient("gitea", events),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="opencode close failed"):
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
assert events == ["opencode", "gitea"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_propagates_gitea_close_failure_after_opencode_closes() -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
value = runtime(
|
||||||
|
ClosingClient("opencode", events),
|
||||||
|
ClosingClient("gitea", events, RuntimeError("gitea close failed")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="gitea close failed"):
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
assert events == ["opencode", "gitea"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_build_runtime_wires_components_without_starting_real_clients(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
token_file = tmp_path / "gitea-token"
|
||||||
|
password_file = tmp_path / "opencode-password"
|
||||||
|
token_file.write_text("token\n")
|
||||||
|
password_file.write_text("password\n")
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None, # type: ignore[call-arg]
|
||||||
|
data_dir=tmp_path / "data",
|
||||||
|
gitea_url="https://gitea.example/",
|
||||||
|
gitea_token_file=token_file,
|
||||||
|
opencode_url="https://opencode.example/",
|
||||||
|
opencode_server_password_file=password_file,
|
||||||
|
install_scripts=["python"],
|
||||||
|
max_concurrent_jobs=4,
|
||||||
|
)
|
||||||
|
initialize = AsyncMock()
|
||||||
|
repository = SimpleNamespace(initialize=initialize)
|
||||||
|
gitea = SimpleNamespace()
|
||||||
|
git = SimpleNamespace()
|
||||||
|
opencode = SimpleNamespace()
|
||||||
|
development = SimpleNamespace()
|
||||||
|
prompts = SimpleNamespace()
|
||||||
|
services = SimpleNamespace()
|
||||||
|
worker = SimpleNamespace()
|
||||||
|
repository_constructor = Mock(return_value=repository)
|
||||||
|
gitea_constructor = Mock(return_value=gitea)
|
||||||
|
git_constructor = Mock(return_value=git)
|
||||||
|
opencode_constructor = Mock(return_value=opencode)
|
||||||
|
development_constructor = Mock(return_value=development)
|
||||||
|
prompt_constructor = Mock(return_value=prompts)
|
||||||
|
services_constructor = Mock(return_value=services)
|
||||||
|
worker_constructor = Mock(return_value=worker)
|
||||||
|
monkeypatch.setattr(runtime_module, "Repository", repository_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "Gitea", gitea_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "Git", git_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "OpenCode", opencode_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "DevelopmentEnvironment", development_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "PromptLibrary", prompt_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "WorkflowServices", services_constructor)
|
||||||
|
monkeypatch.setattr(runtime_module, "Worker", worker_constructor)
|
||||||
|
|
||||||
|
built = await runtime_module.build_runtime(settings)
|
||||||
|
|
||||||
|
assert runtime_module.__file__ is not None
|
||||||
|
package_dir = Path(runtime_module.__file__).parent
|
||||||
|
repository_constructor.assert_called_once_with(
|
||||||
|
settings.database_path, package_dir / "migrations"
|
||||||
|
)
|
||||||
|
initialize.assert_awaited_once_with()
|
||||||
|
gitea_constructor.assert_called_once_with("https://gitea.example", "token")
|
||||||
|
git_constructor.assert_called_once_with(
|
||||||
|
gitea_url="https://gitea.example",
|
||||||
|
username=settings.bot_username,
|
||||||
|
token="token",
|
||||||
|
askpass_path=settings.askpass_path,
|
||||||
|
commit_name=settings.bot_name,
|
||||||
|
commit_email=settings.bot_email,
|
||||||
|
)
|
||||||
|
opencode_constructor.assert_called_once_with(
|
||||||
|
base_url="https://opencode.example",
|
||||||
|
username=settings.opencode_server_username,
|
||||||
|
password="password",
|
||||||
|
schemas_dir=package_dir / "prompts" / "schemas",
|
||||||
|
health_directory=settings.workspaces_dir,
|
||||||
|
required_models=(
|
||||||
|
(settings.plan_model, settings.plan_variant),
|
||||||
|
(settings.implement_model, settings.implement_variant),
|
||||||
|
(settings.explore_model, settings.explore_variant),
|
||||||
|
(settings.research_model, settings.research_variant),
|
||||||
|
),
|
||||||
|
timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
)
|
||||||
|
development_constructor.assert_called_once_with(
|
||||||
|
scripts=["python"],
|
||||||
|
scripts_dir=settings.install_scripts_dir,
|
||||||
|
tools_dir=settings.dev_tools_dir,
|
||||||
|
timeout_seconds=settings.install_script_timeout_seconds,
|
||||||
|
python_version=settings.python_version,
|
||||||
|
dotnet_channel=settings.dotnet_channel,
|
||||||
|
)
|
||||||
|
prompt_constructor.assert_called_once_with()
|
||||||
|
services_constructor.assert_called_once_with(
|
||||||
|
settings=settings,
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
git=git,
|
||||||
|
opencode=opencode,
|
||||||
|
prompts=prompts,
|
||||||
|
development=development,
|
||||||
|
)
|
||||||
|
worker_constructor.assert_called_once_with(
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
opencode=opencode,
|
||||||
|
services=services,
|
||||||
|
poll_seconds=settings.worker_poll_seconds,
|
||||||
|
max_concurrent_jobs=4,
|
||||||
|
workspaces_dir=settings.workspaces_dir,
|
||||||
|
bot_username=settings.bot_username,
|
||||||
|
)
|
||||||
|
assert settings.data_dir.is_dir()
|
||||||
|
assert settings.workspaces_dir.is_dir()
|
||||||
|
assert built.repository is repository
|
||||||
|
assert built.gitea is gitea
|
||||||
|
assert built.git is git
|
||||||
|
assert built.opencode is opencode
|
||||||
|
assert built.worker is worker
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_entrypoint_loads_secrets_writes_tea_login_and_executes_command(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
password = tmp_path / "opencode-password"
|
||||||
|
password.write_text("server-secret")
|
||||||
|
token = tmp_path / "gitea-token"
|
||||||
|
token.write_text("gitea-secret\n")
|
||||||
|
config_home = tmp_path / "config"
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"OPENCODE_SERVER_PASSWORD_FILE": str(password),
|
||||||
|
"AGENTCI_GITEA_TOKEN_FILE": str(token),
|
||||||
|
"AGENTCI_TEA_CONFIG_HOME": str(config_home),
|
||||||
|
"AGENTCI_GITEA_URL": "https://gitea.example",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"/bin/sh",
|
||||||
|
str(ROOT / "scripts" / "entrypoint.sh"),
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
'printf "%s\\n" "$OPENCODE_SERVER_PASSWORD"; printf "arg=%s\\n" "$@"',
|
||||||
|
"child",
|
||||||
|
"first",
|
||||||
|
"second value",
|
||||||
|
],
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert result.stdout.splitlines() == [
|
||||||
|
"server-secret",
|
||||||
|
"arg=first",
|
||||||
|
"arg=second value",
|
||||||
|
]
|
||||||
|
config_path = config_home / "tea" / "config.yml"
|
||||||
|
assert json.loads(config_path.read_text()) == {
|
||||||
|
"logins": [
|
||||||
|
{
|
||||||
|
"name": "agentci",
|
||||||
|
"url": "https://gitea.example",
|
||||||
|
"token": "gitea-secret",
|
||||||
|
"default": True,
|
||||||
|
"version_check": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"preferences": {},
|
||||||
|
}
|
||||||
|
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("prompt", "expected"),
|
||||||
|
[
|
||||||
|
("Username for 'https://gitea.example':", "agentci-bot\n"),
|
||||||
|
("Password for 'https://agentci@gitea.example':", "token-secret\n"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_gitea_askpass_selects_credential_for_prompt(prompt: str, expected: str) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["/bin/sh", str(ROOT / "scripts" / "gitea-askpass.sh"), prompt],
|
||||||
|
env={
|
||||||
|
"AGENTCI_GIT_USERNAME": "agentci-bot",
|
||||||
|
"AGENTCI_GIT_PASSWORD": "token-secret",
|
||||||
|
},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert result.stdout == expected
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
from dataclasses import FrozenInstanceError
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from agentci.domain.events import (
|
|
||||||
CommandReceived,
|
|
||||||
CommentLinked,
|
|
||||||
JobCompleted,
|
|
||||||
JobStarted,
|
|
||||||
PermissionGranted,
|
|
||||||
ServiceRestarted,
|
|
||||||
)
|
|
||||||
from agentci.domain.models import JobKind, JobStatus
|
|
||||||
from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment
|
|
||||||
|
|
||||||
|
|
||||||
def received(body: str = "/agent plan message"):
|
|
||||||
return next_state(
|
|
||||||
None,
|
|
||||||
CommandReceived(
|
|
||||||
job_id="job",
|
|
||||||
delivery_id="delivery",
|
|
||||||
receive_sequence=1,
|
|
||||||
command_body=body,
|
|
||||||
target_key="org/repo:issue:1",
|
|
||||||
repo_owner="org",
|
|
||||||
repo_name="repo",
|
|
||||||
issue_number=1,
|
|
||||||
pr_number=None,
|
|
||||||
requester="alice",
|
|
||||||
comment_id=4,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_parses_and_queues_execution() -> None:
|
|
||||||
transition = next_state(received().state, PermissionGranted(job_id="job"))
|
|
||||||
assert transition.state.status is JobStatus.QUEUED
|
|
||||||
assert transition.state.kind is JobKind.PLAN
|
|
||||||
assert transition.state.message == "message"
|
|
||||||
assert [(item.listener, item.queue) for item in transition.notifications] == [
|
|
||||||
("execute", "jobs"),
|
|
||||||
("reconcile_comment", "control"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_syntax_is_rejected_after_permission() -> None:
|
|
||||||
transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job"))
|
|
||||||
assert transition.state.status is JobStatus.REJECTED
|
|
||||||
assert "Unknown" in (transition.state.error or "")
|
|
||||||
|
|
||||||
|
|
||||||
def test_running_completion_and_restart_are_explicit() -> None:
|
|
||||||
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
|
||||||
running = next_state(queued, JobStarted(job_id="job")).state
|
|
||||||
completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state
|
|
||||||
assert completed.status is JobStatus.SUCCEEDED
|
|
||||||
assert "# Result" in render_job_comment(completed)
|
|
||||||
assert next_state(completed, ServiceRestarted(job_id="job")).state == completed
|
|
||||||
|
|
||||||
|
|
||||||
def test_comment_link_is_allowed_on_terminal_state() -> None:
|
|
||||||
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
|
||||||
running = next_state(queued, JobStarted(job_id="job")).state
|
|
||||||
completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state
|
|
||||||
linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state
|
|
||||||
assert linked.accepted_comment_id == 9
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_is_immutable_and_invalid_transitions_fail() -> None:
|
|
||||||
state = received().state
|
|
||||||
with pytest.raises(FrozenInstanceError):
|
|
||||||
state.stage = "changed" # type: ignore[misc]
|
|
||||||
with pytest.raises(InvalidTransition):
|
|
||||||
next_state(state, JobStarted(job_id="job"))
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from agentci.adapters.storage import Storage
|
|
||||||
from agentci.domain.events import (
|
|
||||||
JobStarted,
|
|
||||||
PermissionDenied,
|
|
||||||
PermissionGranted,
|
|
||||||
WorkflowCreated,
|
|
||||||
)
|
|
||||||
from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus
|
|
||||||
from agentci.state_machine import StateMachine
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def storage(tmp_path: Path) -> Storage:
|
|
||||||
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
|
||||||
value = Storage(tmp_path / "state.sqlite3", migrations)
|
|
||||||
await value.initialize()
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def command(
|
|
||||||
delivery: str,
|
|
||||||
body: str = "/agent plan",
|
|
||||||
*,
|
|
||||||
issue: int = 3,
|
|
||||||
pr: int | None = None,
|
|
||||||
) -> CommandEvent:
|
|
||||||
return CommandEvent(
|
|
||||||
delivery_id=delivery,
|
|
||||||
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
|
||||||
repo_owner="alice",
|
|
||||||
repo_name="repo",
|
|
||||||
issue_number=issue,
|
|
||||||
pr_number=pr,
|
|
||||||
requester="alice",
|
|
||||||
body=body,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None:
|
|
||||||
host = StateMachine(storage)
|
|
||||||
first = await host.receive(command("delivery-1"))
|
|
||||||
duplicate = await host.receive(command("delivery-1"))
|
|
||||||
second = await host.receive(command("delivery-2"))
|
|
||||||
|
|
||||||
assert not first.duplicate
|
|
||||||
assert duplicate.duplicate
|
|
||||||
assert duplicate.state.id == first.state.id
|
|
||||||
assert second.state.receive_sequence == first.state.receive_sequence + 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_received_job_blocks_later_execute_task_for_same_target(
|
|
||||||
storage: Storage,
|
|
||||||
) -> None:
|
|
||||||
host = StateMachine(storage)
|
|
||||||
first = (await host.receive(command("delivery-1"))).state
|
|
||||||
second = (await host.receive(command("delivery-2"))).state
|
|
||||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
|
||||||
|
|
||||||
assert await storage.claim_task("jobs") is None
|
|
||||||
|
|
||||||
await host.evolve("deny-1", PermissionDenied(job_id=first.id))
|
|
||||||
task = await storage.claim_task("jobs")
|
|
||||||
assert task is not None
|
|
||||||
assert task.job_id == second.id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_received_job_does_not_block_a_different_target(storage: Storage) -> None:
|
|
||||||
host = StateMachine(storage)
|
|
||||||
await host.receive(command("delivery-1", issue=3))
|
|
||||||
second = (await host.receive(command("delivery-2", issue=4))).state
|
|
||||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
|
||||||
|
|
||||||
task = await storage.claim_task("jobs")
|
|
||||||
|
|
||||||
assert task is not None
|
|
||||||
assert task.job_id == second.id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_claims_multiple_eligible_targets_without_duplicates(storage: Storage) -> None:
|
|
||||||
host = StateMachine(storage)
|
|
||||||
first = (await host.receive(command("delivery-1", issue=3))).state
|
|
||||||
second = (await host.receive(command("delivery-2", issue=4))).state
|
|
||||||
await host.evolve("grant-1", PermissionGranted(job_id=first.id))
|
|
||||||
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
|
||||||
|
|
||||||
claimed = [await storage.claim_task("jobs"), await storage.claim_task("jobs")]
|
|
||||||
|
|
||||||
assert [task.job_id for task in claimed if task is not None] == [first.id, second.id]
|
|
||||||
assert await storage.claim_task("jobs") is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None:
|
|
||||||
host = StateMachine(storage)
|
|
||||||
state = (await host.receive(command("delivery-1"))).state
|
|
||||||
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
|
||||||
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
|
||||||
with sqlite3.connect(storage.database_path) as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,)
|
|
||||||
).fetchone()
|
|
||||||
assert row is not None
|
|
||||||
assert row[0] is not None
|
|
||||||
assert row[1] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None:
|
|
||||||
workflow = Workflow(
|
|
||||||
id="workflow-1",
|
|
||||||
kind=WorkflowKind.PLAN,
|
|
||||||
repo_owner="alice",
|
|
||||||
repo_name="repo",
|
|
||||||
issue_number=3,
|
|
||||||
workspace_path=tmp_path / "repo",
|
|
||||||
base_sha="abc",
|
|
||||||
artifact="# Plan",
|
|
||||||
status=WorkflowStatus.COMPLETED,
|
|
||||||
)
|
|
||||||
await storage.create_workflow(workflow)
|
|
||||||
await storage.fail_job_workflow("missing-job")
|
|
||||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.status is WorkflowStatus.COMPLETED
|
|
||||||
|
|
||||||
|
|
||||||
async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None:
|
|
||||||
host = StateMachine(storage)
|
|
||||||
state = (await host.receive(command("delivery-1"))).state
|
|
||||||
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
|
||||||
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
|
||||||
workflow = Workflow(
|
|
||||||
id="workflow-atomic",
|
|
||||||
kind=WorkflowKind.PLAN,
|
|
||||||
repo_owner="alice",
|
|
||||||
repo_name="repo",
|
|
||||||
issue_number=3,
|
|
||||||
workspace_path=tmp_path / "repo",
|
|
||||||
base_sha="abc",
|
|
||||||
)
|
|
||||||
result = await host.evolve(
|
|
||||||
"workflow-created",
|
|
||||||
WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
|
|
||||||
)
|
|
||||||
assert result.state.workflow_id == workflow.id
|
|
||||||
assert await storage.get_workflow(workflow.id) is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None:
|
|
||||||
with sqlite3.connect(storage.database_path) as connection:
|
|
||||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
|
|
||||||
assert "receive_sequence" in columns
|
|
||||||
assert "version" not in columns
|
|
||||||
+147
-35
@@ -1,28 +1,31 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient, Response
|
||||||
|
|
||||||
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
|
from agentci.engine.model import IncomingCommand
|
||||||
|
from agentci.webhook import _event_from_payload, router, valid_signature
|
||||||
|
|
||||||
|
|
||||||
class FakeHost:
|
class FakeRepository:
|
||||||
def __init__(self, duplicate: bool = False) -> None:
|
def __init__(self, *, duplicate: bool = False) -> None:
|
||||||
self.events = []
|
self.accepted: list[IncomingCommand] = []
|
||||||
self.duplicate = duplicate
|
self.duplicate = duplicate
|
||||||
|
|
||||||
async def receive(self, event):
|
async def accept(self, event: IncomingCommand) -> SimpleNamespace:
|
||||||
self.events.append(event)
|
self.accepted.append(event)
|
||||||
state = SimpleNamespace(id="job", receive_sequence=1)
|
job = SimpleNamespace(id="job", receive_sequence=1)
|
||||||
return SimpleNamespace(state=state, duplicate=self.duplicate)
|
return SimpleNamespace(job=job, duplicate=self.duplicate)
|
||||||
|
|
||||||
|
|
||||||
def payload(body: str, *, is_pull: bool = False) -> dict:
|
def payload(body: str, *, is_pull: bool = False, requester: str = "alice") -> dict:
|
||||||
value = {
|
value = {
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
|
"comment": {"id": 8, "body": body, "user": {"login": requester}},
|
||||||
"repository": {"name": "repo", "owner": {"login": "org"}},
|
"repository": {"name": "repo", "owner": {"login": "org"}},
|
||||||
"issue": {"number": 4},
|
"issue": {"number": 4},
|
||||||
"is_pull": is_pull,
|
"is_pull": is_pull,
|
||||||
@@ -32,40 +35,149 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def test_command_is_forwarded_without_parsing() -> None:
|
def encoded(value: object) -> bytes:
|
||||||
host = FakeHost()
|
return json.dumps(value).encode()
|
||||||
event = _event_from_payload(
|
|
||||||
"delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
|
|
||||||
|
def sign(body: bytes, secret: bytes = b"secret") -> str:
|
||||||
|
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def post_webhook(
|
||||||
|
repository: FakeRepository,
|
||||||
|
body: bytes,
|
||||||
|
*,
|
||||||
|
event: str = "issue_comment",
|
||||||
|
delivery: str | None = "delivery",
|
||||||
|
signature: str | None = None,
|
||||||
|
bot_username: str = "agentci",
|
||||||
|
) -> Response:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.state.runtime = SimpleNamespace(
|
||||||
|
repository=repository,
|
||||||
|
settings=SimpleNamespace(webhook_secret=b"secret", bot_username=bot_username),
|
||||||
)
|
)
|
||||||
assert event is not None
|
headers = {
|
||||||
response = await _handle_command(SimpleNamespace(state_machine=host), event)
|
"X-Gitea-Event-Type": event,
|
||||||
|
"X-Gitea-Signature": sign(body) if signature is None else signature,
|
||||||
|
}
|
||||||
|
if delivery is not None:
|
||||||
|
headers["X-Gitea-Delivery"] = delivery
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
return await client.post("/webhooks/gitea", content=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_signed_supported_command_is_accepted_once_with_raw_body() -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
body = encoded(payload("/agent iterate\n\nkeep raw body", is_pull=True))
|
||||||
|
|
||||||
|
response = await post_webhook(repository, body)
|
||||||
|
|
||||||
assert response.status_code == 202
|
assert response.status_code == 202
|
||||||
assert host.events[0].body == "/agent iterate\n\nkeep raw body"
|
assert len(repository.accepted) == 1
|
||||||
|
event = repository.accepted[0]
|
||||||
|
assert event.delivery_id == "delivery"
|
||||||
|
assert event.target_key == "org/repo:pr:4"
|
||||||
|
assert event.requester == "alice"
|
||||||
|
assert event.body == "/agent iterate\n\nkeep raw body"
|
||||||
|
|
||||||
|
|
||||||
async def test_duplicate_returns_200() -> None:
|
@pytest.mark.parametrize("event", ["issue_comment", "push"])
|
||||||
event = _event_from_payload("delivery", payload("/agent plan"))
|
async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str) -> None:
|
||||||
assert event is not None
|
repository = FakeRepository()
|
||||||
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
|
|
||||||
assert response.status_code == 200
|
response = await post_webhook(repository, b"not-json", event=event, signature="invalid")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"detail": "Invalid webhook signature"}
|
||||||
|
assert repository.accepted == []
|
||||||
|
|
||||||
|
|
||||||
async def test_missing_delivery_is_rejected() -> None:
|
async def test_signed_unsupported_event_is_ignored_without_parsing() -> None:
|
||||||
event = _event_from_payload("", payload("/agent plan"))
|
repository = FakeRepository()
|
||||||
assert event is not None
|
|
||||||
with pytest.raises(HTTPException) as raised:
|
|
||||||
await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
|
||||||
assert raised.value.status_code == 400
|
|
||||||
|
|
||||||
|
response = await post_webhook(repository, b"not-json", event="push")
|
||||||
|
|
||||||
async def test_non_command_is_ignored() -> None:
|
|
||||||
event = _event_from_payload("delivery", payload("ordinary discussion"))
|
|
||||||
assert event is not None
|
|
||||||
response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
assert repository.accepted == []
|
||||||
|
|
||||||
|
|
||||||
def test_signature_validation() -> None:
|
@pytest.mark.parametrize(
|
||||||
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
|
("command", "requester"),
|
||||||
|
[("ordinary discussion", "alice"), ("/agent plan", "AgentCI")],
|
||||||
|
)
|
||||||
|
async def test_non_command_and_bot_comment_are_ignored(command: str, requester: str) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
|
||||||
|
response = await post_webhook(repository, encoded(payload(command, requester=requester)))
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert repository.accepted == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_delivery_returns_ok() -> None:
|
||||||
|
repository = FakeRepository(duplicate=True)
|
||||||
|
|
||||||
|
response = await post_webhook(repository, encoded(payload("/agent plan")))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(repository.accepted) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body",
|
||||||
|
[
|
||||||
|
b"{",
|
||||||
|
encoded([]),
|
||||||
|
encoded({"action": "created"}),
|
||||||
|
encoded({"action": "created", "comment": {}}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_malformed_json_and_payload_contract_return_bad_request(body: bytes) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
|
||||||
|
response = await post_webhook(repository, body)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "Invalid webhook payload"}
|
||||||
|
assert repository.accepted == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_command_requires_delivery_header() -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
|
||||||
|
response = await post_webhook(repository, encoded(payload("/agent plan")), delivery=None)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "Missing X-Gitea-Delivery"}
|
||||||
|
assert repository.accepted == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_created_comment_is_ignored_without_nested_payload() -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
|
||||||
|
response = await post_webhook(repository, encoded({"action": "edited"}))
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert repository.accepted == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_payload_parser_supports_owner_username_and_pull_request() -> None:
|
||||||
|
value = payload("/agent plan", is_pull=True)
|
||||||
|
value["repository"]["owner"] = {"username": "fallback-owner"}
|
||||||
|
|
||||||
|
event = _event_from_payload("delivery", value)
|
||||||
|
|
||||||
|
assert event is not None
|
||||||
|
assert event.repo_owner == "fallback-owner"
|
||||||
|
assert event.issue_number == 4
|
||||||
|
assert event.pr_number == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_validation_requires_exact_nonempty_digest() -> None:
|
||||||
|
signature = sign(b"{}")
|
||||||
|
|
||||||
assert valid_signature(b"secret", b"{}", signature)
|
assert valid_signature(b"secret", b"{}", signature)
|
||||||
|
assert not valid_signature(b"secret", b"{}", "")
|
||||||
assert not valid_signature(b"secret", b"{}", "bad")
|
assert not valid_signature(b"secret", b"{}", "bad")
|
||||||
|
|||||||
+597
-42
@@ -1,44 +1,586 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from agentci.domain.models import Workflow, WorkflowKind
|
import pytest
|
||||||
from agentci.domain.state_machine import JobState
|
|
||||||
|
from agentci.engine.events import (
|
||||||
|
CommentLinked,
|
||||||
|
JobCompleted,
|
||||||
|
JobFailed,
|
||||||
|
JobStarted,
|
||||||
|
PermissionDenied,
|
||||||
|
PermissionGranted,
|
||||||
|
ServiceRestarted,
|
||||||
|
)
|
||||||
|
from agentci.engine.events import (
|
||||||
|
JobRejected as RejectedEvent,
|
||||||
|
)
|
||||||
|
from agentci.engine.model import (
|
||||||
|
IncomingCommand,
|
||||||
|
Job,
|
||||||
|
JobKind,
|
||||||
|
JobStatus,
|
||||||
|
QueueName,
|
||||||
|
Task,
|
||||||
|
TaskKind,
|
||||||
|
Workflow,
|
||||||
|
WorkflowKind,
|
||||||
|
)
|
||||||
|
from agentci.engine.reducer import render_job_comment
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.gitea import CommentInfo
|
||||||
from agentci.worker import Worker, _safe_error
|
from agentci.worker import Worker, _safe_error
|
||||||
|
from agentci.workflows.render import JobRejected
|
||||||
|
|
||||||
|
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||||
|
|
||||||
|
|
||||||
class FakeStorage:
|
class FakeRepository:
|
||||||
def __init__(self, workflow=None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
value: Job | None = None,
|
||||||
|
*,
|
||||||
|
workflow: Workflow | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.job = value
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
|
self.tasks: list[Task] = []
|
||||||
|
self.claimed_queues: list[QueueName] = []
|
||||||
|
self.completed: list[int] = []
|
||||||
|
self.retried: list[tuple[int, int, str]] = []
|
||||||
|
self.events: list[tuple[str, object]] = []
|
||||||
|
self.operations: list[str] = []
|
||||||
|
self.running: list[Job] = []
|
||||||
|
self.failed_workflows: list[str] = []
|
||||||
|
self.complete_stop: asyncio.Event | None = None
|
||||||
|
|
||||||
async def get_workflow(self, _workflow_id):
|
async def claim_task(self, queue: QueueName) -> Task | None:
|
||||||
|
self.claimed_queues.append(queue)
|
||||||
|
return self.tasks.pop(0) if self.tasks else None
|
||||||
|
|
||||||
|
async def complete_task(self, task_id: int) -> None:
|
||||||
|
self.completed.append(task_id)
|
||||||
|
if self.complete_stop is not None:
|
||||||
|
self.complete_stop.set()
|
||||||
|
|
||||||
|
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
|
||||||
|
self.retried.append((task_id, attempts, error))
|
||||||
|
|
||||||
|
async def get_job(self, _job_id: str) -> Job | None:
|
||||||
|
return self.job
|
||||||
|
|
||||||
|
async def apply(self, event_id: str, event: object) -> SimpleNamespace:
|
||||||
|
self.operations.append(f"apply:{event_id}")
|
||||||
|
self.events.append((event_id, event))
|
||||||
|
if isinstance(event, JobStarted) and self.job is not None:
|
||||||
|
self.job = replace(self.job, status=JobStatus.RUNNING, stage="starting")
|
||||||
|
elif isinstance(event, CommentLinked) and self.job is not None:
|
||||||
|
self.job = replace(self.job, accepted_comment_id=event.comment_id)
|
||||||
|
return SimpleNamespace(job=self.job)
|
||||||
|
|
||||||
|
async def recover_tasks(self) -> None:
|
||||||
|
self.operations.append("recover_tasks")
|
||||||
|
|
||||||
|
async def running_jobs(self) -> list[Job]:
|
||||||
|
self.operations.append("running_jobs")
|
||||||
|
return self.running
|
||||||
|
|
||||||
|
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
|
||||||
return self.workflow
|
return self.workflow
|
||||||
|
|
||||||
|
async def fail_job_workflow(self, job_id: str) -> None:
|
||||||
|
self.failed_workflows.append(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(self, *, permitted: bool = True) -> None:
|
||||||
|
self.permitted = permitted
|
||||||
|
self.permission_calls: list[tuple[str, str, str]] = []
|
||||||
|
self.update_results: list[bool] = [True]
|
||||||
|
self.update_calls: list[tuple[str, str, int, str]] = []
|
||||||
|
self.comments: list[CommentInfo] = []
|
||||||
|
self.issue_comment_calls: list[tuple[str, str, int]] = []
|
||||||
|
self.created_comment_id = 42
|
||||||
|
self.create_calls: list[tuple[str, str, int, str]] = []
|
||||||
|
|
||||||
|
async def has_write_permission(self, owner: str, repo: str, user: str) -> bool:
|
||||||
|
self.permission_calls.append((owner, repo, user))
|
||||||
|
return self.permitted
|
||||||
|
|
||||||
|
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
|
||||||
|
self.update_calls.append((owner, repo, comment_id, body))
|
||||||
|
if len(self.update_results) > 1:
|
||||||
|
return self.update_results.pop(0)
|
||||||
|
return self.update_results[0]
|
||||||
|
|
||||||
|
async def issue_comments(self, owner: str, repo: str, issue: int) -> list[CommentInfo]:
|
||||||
|
self.issue_comment_calls.append((owner, repo, issue))
|
||||||
|
return self.comments
|
||||||
|
|
||||||
|
async def create_comment(self, owner: str, repo: str, issue: int, body: str) -> int:
|
||||||
|
self.create_calls.append((owner, repo, issue, body))
|
||||||
|
return self.created_comment_id
|
||||||
|
|
||||||
|
|
||||||
class FakeOpenCode:
|
class FakeOpenCode:
|
||||||
def __init__(self) -> None:
|
def __init__(self, ready: list[bool] | None = None) -> None:
|
||||||
self.aborted = set()
|
self.ready_results = ready or [True]
|
||||||
|
self.ready_calls = 0
|
||||||
|
self.aborted: list[tuple[str, Path]] = []
|
||||||
|
|
||||||
async def abort(self, session_id, workspace):
|
async def ready(self) -> bool:
|
||||||
self.aborted.add((session_id, workspace))
|
self.ready_calls += 1
|
||||||
|
if len(self.ready_results) > 1:
|
||||||
|
return self.ready_results.pop(0)
|
||||||
|
return self.ready_results[0]
|
||||||
|
|
||||||
|
async def abort(self, session_id: str, workspace: Path) -> None:
|
||||||
|
self.aborted.append((session_id, workspace))
|
||||||
|
|
||||||
|
|
||||||
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
|
def job(
|
||||||
|
*,
|
||||||
|
job_id: str = "job",
|
||||||
|
status: JobStatus = JobStatus.RECEIVED,
|
||||||
|
workflow_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
accepted_comment_id: int | None = None,
|
||||||
|
) -> Job:
|
||||||
|
return Job(
|
||||||
|
id=job_id,
|
||||||
|
target_key="org/repo:issue:1",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=1,
|
||||||
|
delivery_id=f"delivery-{job_id}",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body="/agent plan",
|
||||||
|
kind=JobKind.PLAN if status is not JobStatus.RECEIVED else None,
|
||||||
|
status=status,
|
||||||
|
stage=status.value,
|
||||||
|
workflow_id=workflow_id,
|
||||||
|
runtime_session_id=session_id,
|
||||||
|
accepted_comment_id=accepted_comment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def task(
|
||||||
|
kind: TaskKind = TaskKind.EXECUTE,
|
||||||
|
queue: QueueName = QueueName.JOBS,
|
||||||
|
*,
|
||||||
|
task_id: int = 7,
|
||||||
|
) -> Task:
|
||||||
|
return Task(task_id, "job", "source", kind, queue, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def make_worker(
|
||||||
|
tmp_path: Path,
|
||||||
|
repository: FakeRepository | Repository,
|
||||||
|
*,
|
||||||
|
gitea: FakeGitea | None = None,
|
||||||
|
opencode: FakeOpenCode | None = None,
|
||||||
|
max_concurrent_jobs: int = 2,
|
||||||
|
) -> Worker:
|
||||||
return Worker(
|
return Worker(
|
||||||
storage=storage, # type: ignore[arg-type]
|
repository=repository, # type: ignore[arg-type]
|
||||||
state_machine=SimpleNamespace(), # type: ignore[arg-type]
|
gitea=gitea or FakeGitea(), # type: ignore[arg-type]
|
||||||
gitea=SimpleNamespace(), # type: ignore[arg-type]
|
opencode=opencode or FakeOpenCode(), # type: ignore[arg-type]
|
||||||
opencode=opencode, # type: ignore[arg-type]
|
services=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
|
|
||||||
poll_seconds=1,
|
poll_seconds=1,
|
||||||
max_concurrent_jobs=2,
|
max_concurrent_jobs=max_concurrent_jobs,
|
||||||
workspaces_dir=tmp_path,
|
workspaces_dir=tmp_path,
|
||||||
bot_username="agentci",
|
bot_username="agentci",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
async def test_loop_completes_successful_task(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
queued_task = task(queue=QueueName.CONTROL)
|
||||||
|
repository.tasks = [queued_task]
|
||||||
|
stop = asyncio.Event()
|
||||||
|
handled: list[Task] = []
|
||||||
|
value = make_worker(tmp_path, repository)
|
||||||
|
|
||||||
|
async def handle(current: Task) -> None:
|
||||||
|
handled.append(current)
|
||||||
|
stop.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(value, "_handle", handle)
|
||||||
|
|
||||||
|
await value._loop(QueueName.CONTROL, stop)
|
||||||
|
|
||||||
|
assert handled == [queued_task]
|
||||||
|
assert repository.completed == [queued_task.id]
|
||||||
|
assert repository.retried == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loop_retries_failed_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
queued_task = task(queue=QueueName.CONTROL)
|
||||||
|
repository.tasks = [queued_task]
|
||||||
|
stop = asyncio.Event()
|
||||||
|
value = make_worker(tmp_path, repository)
|
||||||
|
|
||||||
|
async def fail(_current: Task) -> None:
|
||||||
|
stop.set()
|
||||||
|
raise RuntimeError("provider\nfailed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(value, "_handle", fail)
|
||||||
|
|
||||||
|
await value._loop(QueueName.CONTROL, stop)
|
||||||
|
|
||||||
|
assert repository.completed == []
|
||||||
|
assert repository.retried == [
|
||||||
|
(queued_task.id, queued_task.attempts, "RuntimeError: provider failed")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loop_propagates_cancellation_without_rescheduling(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
queued_task = task(queue=QueueName.CONTROL)
|
||||||
|
repository.tasks = [queued_task]
|
||||||
|
stop = asyncio.Event()
|
||||||
|
value = make_worker(tmp_path, repository)
|
||||||
|
|
||||||
|
async def cancel(_current: Task) -> None:
|
||||||
|
stop.set()
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
monkeypatch.setattr(value, "_handle", cancel)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await value._loop(QueueName.CONTROL, stop)
|
||||||
|
|
||||||
|
assert repository.completed == []
|
||||||
|
assert repository.retried == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loop_completes_task_whose_job_no_longer_exists(tmp_path: Path) -> None:
|
||||||
|
repository = FakeRepository(None)
|
||||||
|
missing_job_task = task(queue=QueueName.CONTROL)
|
||||||
|
repository.tasks = [missing_job_task]
|
||||||
|
stop = asyncio.Event()
|
||||||
|
repository.complete_stop = stop
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._loop(QueueName.CONTROL, stop)
|
||||||
|
|
||||||
|
assert repository.completed == [missing_job_task.id]
|
||||||
|
assert repository.retried == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_jobs_queue_waits_for_provider_readiness(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
queued_task = task()
|
||||||
|
repository.tasks = [queued_task]
|
||||||
|
opencode = FakeOpenCode([False, True])
|
||||||
|
stop = asyncio.Event()
|
||||||
|
waits: list[asyncio.Event] = []
|
||||||
|
value = make_worker(tmp_path, repository, opencode=opencode)
|
||||||
|
|
||||||
|
async def wait(current_stop: asyncio.Event) -> None:
|
||||||
|
waits.append(current_stop)
|
||||||
|
|
||||||
|
async def handle(_current: Task) -> None:
|
||||||
|
stop.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(value, "_wait", wait)
|
||||||
|
monkeypatch.setattr(value, "_handle", handle)
|
||||||
|
|
||||||
|
await value._loop(QueueName.JOBS, stop)
|
||||||
|
|
||||||
|
assert opencode.ready_calls == 2
|
||||||
|
assert waits == [stop]
|
||||||
|
assert repository.claimed_queues == [QueueName.JOBS]
|
||||||
|
assert repository.completed == [queued_task.id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_recovers_before_starting_configured_consumers(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
value = make_worker(tmp_path, FakeRepository(), max_concurrent_jobs=3)
|
||||||
|
calls: list[str | QueueName] = []
|
||||||
|
|
||||||
|
async def recover() -> None:
|
||||||
|
calls.append("recover")
|
||||||
|
|
||||||
|
async def loop(queue: QueueName, _stop: asyncio.Event) -> None:
|
||||||
|
calls.append(queue)
|
||||||
|
|
||||||
|
monkeypatch.setattr(value, "_recover", recover)
|
||||||
|
monkeypatch.setattr(value, "_loop", loop)
|
||||||
|
|
||||||
|
await value.run(asyncio.Event())
|
||||||
|
|
||||||
|
assert calls[0] == "recover"
|
||||||
|
assert calls.count(QueueName.CONTROL) == 1
|
||||||
|
assert calls.count(QueueName.JOBS) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("permitted", "event_type", "suffix"),
|
||||||
|
[
|
||||||
|
(True, PermissionGranted, "permission-granted"),
|
||||||
|
(False, PermissionDenied, "permission-denied"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_authorize_records_permission_outcome(
|
||||||
|
tmp_path: Path,
|
||||||
|
permitted: bool,
|
||||||
|
event_type: type[PermissionGranted] | type[PermissionDenied],
|
||||||
|
suffix: str,
|
||||||
|
) -> None:
|
||||||
|
current = job()
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
gitea = FakeGitea(permitted=permitted)
|
||||||
|
authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL)
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current)
|
||||||
|
|
||||||
|
assert gitea.permission_calls == [("org", "repo", "alice")]
|
||||||
|
assert repository.events[0][0] == f"task:{authorize.id}:{suffix}"
|
||||||
|
assert isinstance(repository.events[0][1], event_type)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorize_ignores_job_after_received_status(tmp_path: Path) -> None:
|
||||||
|
current = job(status=JobStatus.QUEUED)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
gitea = FakeGitea()
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository, gitea=gitea)._authorize(
|
||||||
|
task(TaskKind.AUTHORIZE, QueueName.CONTROL), current
|
||||||
|
)
|
||||||
|
|
||||||
|
assert gitea.permission_calls == []
|
||||||
|
assert repository.events == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorize_integrates_with_real_repository(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS)
|
||||||
|
await repository.initialize()
|
||||||
|
accepted = await repository.accept(
|
||||||
|
IncomingCommand(
|
||||||
|
delivery_id="delivery-real",
|
||||||
|
comment_id=1,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
body="/agent plan write tests",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
authorize = await repository.claim_task(QueueName.CONTROL)
|
||||||
|
assert authorize is not None
|
||||||
|
assert authorize.kind is TaskKind.AUTHORIZE
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._handle(authorize)
|
||||||
|
|
||||||
|
persisted = await repository.get_job(accepted.job.id)
|
||||||
|
assert persisted is not None
|
||||||
|
assert persisted.status is JobStatus.QUEUED
|
||||||
|
assert persisted.kind is JobKind.PLAN
|
||||||
|
assert persisted.message == "write tests"
|
||||||
|
execute = await repository.claim_task(QueueName.JOBS)
|
||||||
|
assert execute is not None
|
||||||
|
assert execute.kind is TaskKind.EXECUTE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_execute_completes_with_workflow_comment(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
current = job(status=JobStatus.QUEUED)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
value = make_worker(tmp_path, repository)
|
||||||
|
|
||||||
|
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||||
|
return "final workflow body"
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||||
|
execute = task()
|
||||||
|
|
||||||
|
await value._execute(execute, current)
|
||||||
|
|
||||||
|
assert [event_id for event_id, _ in repository.events] == [
|
||||||
|
f"task:{execute.id}:started",
|
||||||
|
f"task:{execute.id}:completed",
|
||||||
|
]
|
||||||
|
assert isinstance(repository.events[0][1], JobStarted)
|
||||||
|
completed = repository.events[1][1]
|
||||||
|
assert isinstance(completed, JobCompleted)
|
||||||
|
assert completed.comment_body == "final workflow body"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_execute_records_expected_rejection(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
current = job(status=JobStatus.QUEUED)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
|
||||||
|
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||||
|
raise JobRejected("pull request is closed")
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||||
|
|
||||||
|
rejected = repository.events[-1][1]
|
||||||
|
assert isinstance(rejected, RejectedEvent)
|
||||||
|
assert rejected.reason == "pull request is closed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_execute_records_failure_at_latest_persisted_stage(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
current = job(status=JobStatus.QUEUED)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
|
||||||
|
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||||
|
assert repository.job is not None
|
||||||
|
repository.job = replace(repository.job, stage="cloning")
|
||||||
|
raise RuntimeError("provider\nfailed")
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||||
|
|
||||||
|
failed = repository.events[-1][1]
|
||||||
|
assert isinstance(failed, JobFailed)
|
||||||
|
assert failed.stage == "cloning"
|
||||||
|
assert failed.error == "RuntimeError: provider failed"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_execute_marks_running_job_interrupted_after_restart(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
current = job(status=JobStatus.RUNNING)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
|
||||||
|
async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||||
|
pytest.fail("running jobs must not be dispatched again")
|
||||||
|
|
||||||
|
monkeypatch.setattr("agentci.worker.dispatch", unexpected_dispatch)
|
||||||
|
execute = task()
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._execute(execute, current)
|
||||||
|
|
||||||
|
assert repository.events[0][0] == f"task:{execute.id}:interrupted"
|
||||||
|
assert isinstance(repository.events[0][1], ServiceRestarted)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [JobStatus.RECEIVED, JobStatus.SUCCEEDED])
|
||||||
|
async def test_execute_ignores_ineligible_status(tmp_path: Path, status: JobStatus) -> None:
|
||||||
|
current = job(status=status)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||||
|
|
||||||
|
assert repository.events == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_updates_linked_comment_in_place(tmp_path: Path) -> None:
|
||||||
|
current = job(status=JobStatus.QUEUED, accepted_comment_id=19)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
gitea = FakeGitea()
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(
|
||||||
|
task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), current
|
||||||
|
)
|
||||||
|
|
||||||
|
assert gitea.update_calls == [("org", "repo", 19, render_job_comment(current))]
|
||||||
|
assert gitea.issue_comment_calls == []
|
||||||
|
assert gitea.create_calls == []
|
||||||
|
assert repository.events == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_finds_oldest_matching_bot_comment_and_links_it(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current = job(status=JobStatus.QUEUED, accepted_comment_id=99)
|
||||||
|
marker = f"<!-- agentci:job id={current.id} -->"
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
gitea = FakeGitea()
|
||||||
|
gitea.update_results = [False, True]
|
||||||
|
gitea.comments = [
|
||||||
|
CommentInfo(8, "agentci", f"{marker}\nnewer", ""),
|
||||||
|
CommentInfo(3, "AgentCI", f"{marker}\nolder", ""),
|
||||||
|
CommentInfo(1, "alice", f"{marker}\nnot the bot", ""),
|
||||||
|
CommentInfo(2, "agentci", f"prefix {marker}", ""),
|
||||||
|
]
|
||||||
|
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
|
||||||
|
|
||||||
|
assert gitea.issue_comment_calls == [("org", "repo", 1)]
|
||||||
|
assert gitea.create_calls == []
|
||||||
|
assert [call[2] for call in gitea.update_calls] == [99, 3]
|
||||||
|
assert repository.events[0][0] == f"task:{reconcile.id}:comment:3"
|
||||||
|
linked = repository.events[0][1]
|
||||||
|
assert isinstance(linked, CommentLinked)
|
||||||
|
assert linked.comment_id == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_creates_links_and_populates_missing_comment(tmp_path: Path) -> None:
|
||||||
|
current = job(status=JobStatus.SUCCEEDED)
|
||||||
|
repository = FakeRepository(current)
|
||||||
|
gitea = FakeGitea()
|
||||||
|
gitea.comments = [CommentInfo(1, "alice", "unrelated", "")]
|
||||||
|
reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current)
|
||||||
|
|
||||||
|
body = render_job_comment(current)
|
||||||
|
assert gitea.create_calls == [("org", "repo", 1, body)]
|
||||||
|
assert gitea.update_calls == [("org", "repo", 42, body)]
|
||||||
|
assert repository.events[0][0] == f"task:{reconcile.id}:comment:42"
|
||||||
|
linked = repository.events[0][1]
|
||||||
|
assert isinstance(linked, CommentLinked)
|
||||||
|
assert linked.comment_id == 42
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recovery_resets_tasks_before_interrupting_running_jobs(tmp_path: Path) -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
repository.running = [
|
||||||
|
job(job_id="first", status=JobStatus.RUNNING),
|
||||||
|
job(job_id="second", status=JobStatus.RUNNING),
|
||||||
|
]
|
||||||
|
|
||||||
|
await make_worker(tmp_path, repository)._recover()
|
||||||
|
|
||||||
|
assert repository.operations == [
|
||||||
|
"recover_tasks",
|
||||||
|
"running_jobs",
|
||||||
|
"apply:recovery:first:service-restarted",
|
||||||
|
"apply:recovery:second:service-restarted",
|
||||||
|
]
|
||||||
|
assert all(isinstance(event, ServiceRestarted) for _, event in repository.events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("primary", "reviewer", "expected"),
|
||||||
|
[
|
||||||
|
("primary", "reviewer", {"primary", "reviewer"}),
|
||||||
|
("same-session", "same-session", {"same-session"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_abort_collects_and_deduplicates_workflow_sessions(
|
||||||
|
tmp_path: Path,
|
||||||
|
primary: str,
|
||||||
|
reviewer: str,
|
||||||
|
expected: set[str],
|
||||||
|
) -> None:
|
||||||
workspace = tmp_path / "workflow" / "repo"
|
workspace = tmp_path / "workflow" / "repo"
|
||||||
workflow = Workflow(
|
workflow = Workflow(
|
||||||
id="flow",
|
id="flow",
|
||||||
@@ -48,46 +590,59 @@ async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
|||||||
issue_number=1,
|
issue_number=1,
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
base_sha="base",
|
base_sha="base",
|
||||||
primary_session_id="primary",
|
primary_session_id=primary,
|
||||||
reviewer_session_id="reviewer",
|
reviewer_session_id=reviewer,
|
||||||
)
|
)
|
||||||
opencode = FakeOpenCode()
|
opencode = FakeOpenCode()
|
||||||
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
|
|
||||||
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
|
await make_worker(
|
||||||
cast(JobState, state)
|
tmp_path, FakeRepository(workflow=workflow), opencode=opencode
|
||||||
)
|
)._abort_job_sessions(job(workflow_id="flow"))
|
||||||
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
|
|
||||||
|
assert set(opencode.aborted) == {(session, workspace) for session in expected}
|
||||||
|
|
||||||
|
|
||||||
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
|
async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None:
|
||||||
opencode = FakeOpenCode()
|
opencode = FakeOpenCode()
|
||||||
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
|
|
||||||
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
|
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(
|
||||||
cast(JobState, state)
|
job(workflow_id="missing", session_id="session")
|
||||||
)
|
)
|
||||||
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
|
|
||||||
|
assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")]
|
||||||
|
|
||||||
|
|
||||||
async def test_run_starts_configured_job_consumers(tmp_path: Path, monkeypatch) -> None:
|
async def test_abort_does_not_mix_one_shot_session_into_existing_workflow(
|
||||||
value = worker(tmp_path, FakeStorage(), FakeOpenCode())
|
tmp_path: Path,
|
||||||
queues = []
|
) -> None:
|
||||||
|
workflow = Workflow(
|
||||||
|
id="flow",
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
workspace_path=tmp_path / "workflow" / "repo",
|
||||||
|
base_sha="base",
|
||||||
|
)
|
||||||
|
opencode = FakeOpenCode()
|
||||||
|
|
||||||
async def recover() -> None:
|
await make_worker(
|
||||||
pass
|
tmp_path, FakeRepository(workflow=workflow), opencode=opencode
|
||||||
|
)._abort_job_sessions(job(workflow_id="flow", session_id="one-shot"))
|
||||||
|
|
||||||
async def loop(queue, _stop) -> None:
|
assert opencode.aborted == []
|
||||||
queues.append(queue)
|
|
||||||
|
|
||||||
monkeypatch.setattr(value, "_recover", recover)
|
|
||||||
monkeypatch.setattr(value, "_loop", loop)
|
|
||||||
|
|
||||||
await value.run(asyncio.Event())
|
async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
|
||||||
|
opencode = FakeOpenCode()
|
||||||
|
|
||||||
assert queues.count("control") == 1
|
await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job())
|
||||||
assert queues.count("jobs") == 2
|
|
||||||
|
assert opencode.aborted == []
|
||||||
|
|
||||||
|
|
||||||
def test_safe_error_is_single_line_and_bounded() -> None:
|
def test_safe_error_is_single_line_and_bounded() -> None:
|
||||||
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
|
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
|
||||||
|
|
||||||
assert "\n" not in value
|
assert "\n" not in value
|
||||||
assert len(value) == 1000
|
assert len(value) == 1000
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from dataclasses import replace
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine.model import Job, JobKind
|
||||||
|
from agentci.engine.run import JobRun
|
||||||
|
from agentci.workflows import dispatch as dispatch_module
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
ROUTES = [
|
||||||
|
(JobKind.PLAN, "create_plan"),
|
||||||
|
(JobKind.DISCUSS, "discuss_plan"),
|
||||||
|
(JobKind.ITERATE_PLAN, "iterate_plan"),
|
||||||
|
(JobKind.IMPLEMENT, "implement"),
|
||||||
|
(JobKind.ITERATE_IMPLEMENT, "iterate_implementation"),
|
||||||
|
(JobKind.FIX, "fix_pull_request"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_job(kind: JobKind | None) -> Job:
|
||||||
|
return Job(
|
||||||
|
id="job-1",
|
||||||
|
kind=kind,
|
||||||
|
target_key="org/repo:issue:7",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=11,
|
||||||
|
delivery_id="delivery-1",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body="/agent plan",
|
||||||
|
message="request",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("kind", "expected_route"), ROUTES)
|
||||||
|
async def test_dispatch_routes_every_job_kind_and_returns_body(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
kind: JobKind,
|
||||||
|
expected_route: str,
|
||||||
|
) -> None:
|
||||||
|
calls: list[tuple[str, Job]] = []
|
||||||
|
|
||||||
|
def route(name: str):
|
||||||
|
async def invoke(job: Job, _run: JobRun, _services: WorkflowServices) -> str:
|
||||||
|
calls.append((name, job))
|
||||||
|
return f"body from {name}"
|
||||||
|
|
||||||
|
return invoke
|
||||||
|
|
||||||
|
for _, name in ROUTES:
|
||||||
|
monkeypatch.setattr(dispatch_module, name, route(name))
|
||||||
|
|
||||||
|
job = make_job(kind)
|
||||||
|
body = await dispatch_module.dispatch(
|
||||||
|
job,
|
||||||
|
cast(JobRun, object()),
|
||||||
|
cast(WorkflowServices, object()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body == f"body from {expected_route}"
|
||||||
|
assert calls == [(expected_route, job)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispatch_rejects_unparsed_command() -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="Cannot dispatch an unparsed command"):
|
||||||
|
await dispatch_module.dispatch(
|
||||||
|
make_job(None),
|
||||||
|
cast(JobRun, object()),
|
||||||
|
cast(WorkflowServices, object()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedKind(StrEnum):
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dispatch_rejects_unsupported_kind() -> None:
|
||||||
|
unsupported = UnsupportedKind.UNKNOWN
|
||||||
|
job = replace(make_job(JobKind.PLAN), kind=cast(JobKind, unsupported))
|
||||||
|
|
||||||
|
with pytest.raises(KeyError) as error:
|
||||||
|
await dispatch_module.dispatch(
|
||||||
|
job,
|
||||||
|
cast(JobRun, object()),
|
||||||
|
cast(WorkflowServices, object()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error.value.args == (unsupported,)
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||||
|
from agentci.engine.run import JobRun
|
||||||
|
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||||
|
from agentci.workflows.implementation import implement
|
||||||
|
from agentci.workflows.model import AgentResult, ReviewReport
|
||||||
|
from agentci.workflows.render import JobRejected
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRun(JobRun):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.stages: list[str] = []
|
||||||
|
self.created_workflows: list[tuple[Workflow, str]] = []
|
||||||
|
self.linked_sessions: list[str] = []
|
||||||
|
|
||||||
|
async def stage(self, stage: str) -> None:
|
||||||
|
self.stages.append(stage)
|
||||||
|
|
||||||
|
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||||
|
self.created_workflows.append((workflow, stage))
|
||||||
|
|
||||||
|
async def link_session(self, session_id: str) -> None:
|
||||||
|
self.linked_sessions.append(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
implementations: list[Workflow] | None = None,
|
||||||
|
plan: Workflow | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.implementations = implementations or []
|
||||||
|
self.plan = plan
|
||||||
|
self.saved_workflows: list[Workflow] = []
|
||||||
|
|
||||||
|
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
|
||||||
|
return self.implementations
|
||||||
|
|
||||||
|
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||||
|
return self.plan
|
||||||
|
|
||||||
|
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||||
|
return {99}
|
||||||
|
|
||||||
|
async def save_workflow(self, workflow: Workflow) -> None:
|
||||||
|
self.saved_workflows.append(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
existing_pulls: dict[int, PullRequestInfo] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.existing_pulls = existing_pulls or {}
|
||||||
|
self.created_pulls: list[tuple[str, str, dict[str, str]]] = []
|
||||||
|
self.default_branch_calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def default_branch(self, owner: str, repo: str) -> str:
|
||||||
|
self.default_branch_calls.append((owner, repo))
|
||||||
|
return "main"
|
||||||
|
|
||||||
|
async def issue(self, *_args: object) -> IssueInfo:
|
||||||
|
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
|
||||||
|
|
||||||
|
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||||
|
return [
|
||||||
|
CommentInfo(12, "alice", "Please cover empty input.", "2026-07-01"),
|
||||||
|
CommentInfo(99, "agentci", "Job queued", "2026-07-02"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
|
||||||
|
return self.existing_pulls[number]
|
||||||
|
|
||||||
|
async def create_pull_request(self, owner: str, repo: str, **values: str) -> PullRequestInfo:
|
||||||
|
self.created_pulls.append((owner, repo, values))
|
||||||
|
return pull(number=42, branch=values["head"])
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingGit:
|
||||||
|
def __init__(self, *, changed: bool = True) -> None:
|
||||||
|
self.changed = changed
|
||||||
|
self.calls: list[tuple[Any, ...]] = []
|
||||||
|
|
||||||
|
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||||
|
self.calls.append(("clone", owner, repo, branch, destination))
|
||||||
|
return "base-sha"
|
||||||
|
|
||||||
|
async def create_branch(self, workspace: Path, branch: str) -> None:
|
||||||
|
self.calls.append(("create_branch", workspace, branch))
|
||||||
|
|
||||||
|
async def has_changes(self, workspace: Path) -> bool:
|
||||||
|
self.calls.append(("has_changes", workspace))
|
||||||
|
return self.changed
|
||||||
|
|
||||||
|
async def diff_check(self, workspace: Path) -> None:
|
||||||
|
self.calls.append(("diff_check", workspace))
|
||||||
|
|
||||||
|
async def commit(self, workspace: Path, message: str) -> str:
|
||||||
|
self.calls.append(("commit", workspace, message))
|
||||||
|
return "commit-sha"
|
||||||
|
|
||||||
|
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
|
||||||
|
self.calls.append(("push", workspace, branch, set_upstream))
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingDevelopment:
|
||||||
|
description = "Python 3.13"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.workspaces: list[Path] = []
|
||||||
|
|
||||||
|
async def prepare(self, workspace: Path) -> None:
|
||||||
|
self.workspaces.append(workspace)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingPrompts:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
||||||
|
def render(self, name: str, **values: str) -> str:
|
||||||
|
self.calls.append((name, values))
|
||||||
|
return f"rendered {name}"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingOpenCode:
|
||||||
|
def __init__(self, result: AgentResult, report: ReviewReport) -> None:
|
||||||
|
self.result = result
|
||||||
|
self.report = report
|
||||||
|
self.created_sessions: list[tuple[Path, str]] = []
|
||||||
|
self.resume_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def create_session(self, workspace: Path, title: str) -> str:
|
||||||
|
self.created_sessions.append((workspace, title))
|
||||||
|
return f"{title}-session"
|
||||||
|
|
||||||
|
async def resume(self, **values: Any) -> AgentResult | ReviewReport:
|
||||||
|
self.resume_calls.append(values)
|
||||||
|
if values["result_type"] is AgentResult:
|
||||||
|
return self.result
|
||||||
|
assert values["result_type"] is ReviewReport
|
||||||
|
return self.report
|
||||||
|
|
||||||
|
|
||||||
|
def job() -> Job:
|
||||||
|
return Job(
|
||||||
|
id="job-1",
|
||||||
|
kind=JobKind.IMPLEMENT,
|
||||||
|
target_key="org/repo:issue:7",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=10,
|
||||||
|
delivery_id="delivery-1",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body="/agent implement",
|
||||||
|
message="Keep the change focused.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow(*, pr_number: int | None = None) -> Workflow:
|
||||||
|
return Workflow(
|
||||||
|
id=f"old-{pr_number}",
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
workspace_path=Path("/old/repo"),
|
||||||
|
base_sha="old-sha",
|
||||||
|
pr_number=pr_number,
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pull(
|
||||||
|
*,
|
||||||
|
number: int = 8,
|
||||||
|
state: str = "open",
|
||||||
|
merged: bool = False,
|
||||||
|
branch: str = "agent/old",
|
||||||
|
) -> PullRequestInfo:
|
||||||
|
return PullRequestInfo(
|
||||||
|
number=number,
|
||||||
|
title="Existing PR",
|
||||||
|
body="Body",
|
||||||
|
state=state,
|
||||||
|
merged=merged,
|
||||||
|
base_branch="main",
|
||||||
|
head_branch=branch,
|
||||||
|
head_sha="head-sha",
|
||||||
|
head_owner="org",
|
||||||
|
head_repo="repo",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_services(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
changed: bool = True,
|
||||||
|
implementations: list[Workflow] | None = None,
|
||||||
|
existing_pulls: dict[int, PullRequestInfo] | None = None,
|
||||||
|
plan: Workflow | None = None,
|
||||||
|
) -> tuple[
|
||||||
|
WorkflowServices,
|
||||||
|
FakeRepository,
|
||||||
|
FakeGitea,
|
||||||
|
RecordingGit,
|
||||||
|
RecordingDevelopment,
|
||||||
|
RecordingPrompts,
|
||||||
|
RecordingOpenCode,
|
||||||
|
]:
|
||||||
|
repository = FakeRepository(implementations=implementations, plan=plan)
|
||||||
|
gitea = FakeGitea(existing_pulls=existing_pulls)
|
||||||
|
git = RecordingGit(changed=changed)
|
||||||
|
development = RecordingDevelopment()
|
||||||
|
prompts = RecordingPrompts()
|
||||||
|
opencode = RecordingOpenCode(
|
||||||
|
AgentResult(
|
||||||
|
summary_markdown="# Implement widget\n\nHandled empty input.",
|
||||||
|
tests=["pytest: passed"],
|
||||||
|
),
|
||||||
|
ReviewReport(summary="Ready", findings=[]),
|
||||||
|
)
|
||||||
|
services = cast(
|
||||||
|
WorkflowServices,
|
||||||
|
SimpleNamespace(
|
||||||
|
settings=SimpleNamespace(
|
||||||
|
branch_prefix="agent",
|
||||||
|
workspaces_dir=tmp_path / "workspaces",
|
||||||
|
gitea_url="https://git.example.test",
|
||||||
|
implement_model="provider/model",
|
||||||
|
implement_variant="high",
|
||||||
|
implement_review_rounds=2,
|
||||||
|
),
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
git=git,
|
||||||
|
opencode=opencode,
|
||||||
|
prompts=prompts,
|
||||||
|
development=development,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return services, repository, gitea, git, development, prompts, opencode
|
||||||
|
|
||||||
|
|
||||||
|
async def test_initial_implementation_completes_review_commit_push_and_pr(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
stale = workflow(pr_number=8)
|
||||||
|
services, repository, gitea, git, development, prompts, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
implementations=[stale],
|
||||||
|
existing_pulls={8: pull(state="closed")},
|
||||||
|
)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
body = await implement(job(), run, services)
|
||||||
|
|
||||||
|
assert len(run.created_workflows) == 1
|
||||||
|
created, created_stage = run.created_workflows[0]
|
||||||
|
assert created_stage == "installing development environment"
|
||||||
|
assert created.status is WorkflowStatus.ACTIVE
|
||||||
|
assert created.branch == f"agent/issue-7-{created.id[:8]}"
|
||||||
|
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
|
||||||
|
assert created.base_sha == "base-sha"
|
||||||
|
assert development.workspaces == [created.workspace_path]
|
||||||
|
assert run.linked_sessions == ["implementation-session"]
|
||||||
|
assert opencode.created_sessions == [
|
||||||
|
(created.workspace_path, "implementation"),
|
||||||
|
(created.workspace_path, "implementation-review"),
|
||||||
|
]
|
||||||
|
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||||
|
AgentResult,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
|
||||||
|
initial_prompt = prompts.calls[0]
|
||||||
|
assert initial_prompt[0] == "implement_initial"
|
||||||
|
assert initial_prompt[1]["artifact"] == "(no canonical plan)"
|
||||||
|
assert initial_prompt[1]["request"] == "Keep the change focused."
|
||||||
|
assert "Please cover empty input." in initial_prompt[1]["context"]
|
||||||
|
assert "Job queued" not in initial_prompt[1]["context"]
|
||||||
|
|
||||||
|
assert git.calls == [
|
||||||
|
("clone", "org", "repo", "main", created.workspace_path),
|
||||||
|
("create_branch", created.workspace_path, created.branch),
|
||||||
|
("has_changes", created.workspace_path),
|
||||||
|
("diff_check", created.workspace_path),
|
||||||
|
("commit", created.workspace_path, "agent: Implement widget"),
|
||||||
|
("push", created.workspace_path, created.branch, True),
|
||||||
|
]
|
||||||
|
assert gitea.created_pulls == [
|
||||||
|
(
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
{
|
||||||
|
"title": "Agent: Fix widget",
|
||||||
|
"body": (
|
||||||
|
"Closes #7\n\n"
|
||||||
|
"## Implementation\n\n# Implement widget\n\nHandled empty input.\n\n"
|
||||||
|
"## Validation\n\n- pytest: passed\n\n_Created by Agent CI._"
|
||||||
|
),
|
||||||
|
"head": created.branch,
|
||||||
|
"base": "main",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
completed = repository.saved_workflows[-1]
|
||||||
|
assert completed.id == created.id
|
||||||
|
assert completed.status is WorkflowStatus.COMPLETED
|
||||||
|
assert completed.pr_number == 42
|
||||||
|
assert completed.primary_session_id == "implementation-session"
|
||||||
|
assert completed.reviewer_session_id == "implementation-review-session"
|
||||||
|
assert AgentResult.model_validate_json(completed.artifact or "").tests == ["pytest: passed"]
|
||||||
|
assert json.loads(completed.review_json or "") == {
|
||||||
|
"summary": "Ready",
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
assert body == (
|
||||||
|
f"<!-- agentci:implementation workflow={created.id} -->\n"
|
||||||
|
"Pull request created: https://git.example.test/org/repo/pulls/42\n\n"
|
||||||
|
"## Agent result\n\n# Implement widget\n\nHandled empty input.\n\n"
|
||||||
|
"## Validation\n\n- pytest: passed\n\nCommit: `commit-sha`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
JobRejected,
|
||||||
|
match="OpenCode completed without producing any file changes",
|
||||||
|
):
|
||||||
|
await implement(job(), run, services)
|
||||||
|
|
||||||
|
assert [call[0] for call in git.calls] == [
|
||||||
|
"clone",
|
||||||
|
"create_branch",
|
||||||
|
"has_changes",
|
||||||
|
]
|
||||||
|
assert gitea.created_pulls == []
|
||||||
|
assert repository.saved_workflows
|
||||||
|
assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows)
|
||||||
|
assert "creating pull request" not in run.stages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("existing_pull", "message"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
pull(state="open"),
|
||||||
|
"Agent PR #8 is already open. Use `/agent iterate` on that pull request.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
pull(state="closed", merged=True),
|
||||||
|
"Agent PR #8 has already been merged for this issue.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone(
|
||||||
|
tmp_path: Path,
|
||||||
|
existing_pull: PullRequestInfo,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
services, _, gitea, git, development, _, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
implementations=[workflow(pr_number=8)],
|
||||||
|
existing_pulls={8: existing_pull},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await implement(job(), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert str(error.value) == message
|
||||||
|
assert git.calls == []
|
||||||
|
assert development.workspaces == []
|
||||||
|
assert opencode.resume_calls == []
|
||||||
|
assert gitea.default_branch_calls == []
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||||
|
from agentci.engine.run import JobRun
|
||||||
|
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||||
|
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
|
||||||
|
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
|
||||||
|
from agentci.workflows.render import JobRejected
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRun(JobRun):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.stages: list[str] = []
|
||||||
|
self.created_workflows: list[tuple[Workflow, str]] = []
|
||||||
|
self.linked_workflows: list[tuple[str, str]] = []
|
||||||
|
self.linked_sessions: list[str] = []
|
||||||
|
|
||||||
|
async def stage(self, stage: str) -> None:
|
||||||
|
self.stages.append(stage)
|
||||||
|
|
||||||
|
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||||
|
self.created_workflows.append((workflow, stage))
|
||||||
|
|
||||||
|
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||||
|
self.linked_workflows.append((workflow_id, stage))
|
||||||
|
|
||||||
|
async def link_session(self, session_id: str) -> None:
|
||||||
|
self.linked_sessions.append(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
latest: Workflow | None = None,
|
||||||
|
implementations: list[Workflow] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.latest = latest
|
||||||
|
self.implementations = implementations or []
|
||||||
|
self.saved_workflows: list[Workflow] = []
|
||||||
|
self.latest_calls = 0
|
||||||
|
|
||||||
|
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||||
|
self.latest_calls += 1
|
||||||
|
return self.latest
|
||||||
|
|
||||||
|
async def implementation_workflows(self, *_args: object) -> list[Workflow]:
|
||||||
|
return self.implementations
|
||||||
|
|
||||||
|
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||||
|
return {91}
|
||||||
|
|
||||||
|
async def save_workflow(self, workflow: Workflow) -> None:
|
||||||
|
self.saved_workflows.append(workflow)
|
||||||
|
self.latest = workflow
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(self, pulls: dict[int, PullRequestInfo] | None = None) -> None:
|
||||||
|
self.pulls = pulls or {}
|
||||||
|
self.default_branch_calls: list[tuple[str, str]] = []
|
||||||
|
self.pull_calls: list[int] = []
|
||||||
|
|
||||||
|
async def default_branch(self, owner: str, repo: str) -> str:
|
||||||
|
self.default_branch_calls.append((owner, repo))
|
||||||
|
return "trunk"
|
||||||
|
|
||||||
|
async def issue(self, *_args: object) -> IssueInfo:
|
||||||
|
return IssueInfo(3, "Plan feature", "Build the feature.", "open")
|
||||||
|
|
||||||
|
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||||
|
return [
|
||||||
|
CommentInfo(4, "alice", "Use the existing API.", "2026-07-01"),
|
||||||
|
CommentInfo(91, "agentci", "Job queued", "2026-07-02"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo:
|
||||||
|
self.pull_calls.append(number)
|
||||||
|
return self.pulls[number]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingGit:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.clone_calls: list[tuple[str, str, str, Path]] = []
|
||||||
|
|
||||||
|
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||||
|
self.clone_calls.append((owner, repo, branch, destination))
|
||||||
|
return "base-sha"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingPrompts:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
||||||
|
def render(self, name: str, **values: str) -> str:
|
||||||
|
self.calls.append((name, values))
|
||||||
|
return f"rendered {name}"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingOpenCode:
|
||||||
|
def __init__(self, responses: list[BaseModel] | None = None) -> None:
|
||||||
|
self.responses = list(responses or [])
|
||||||
|
self.created_sessions: list[tuple[Path, str]] = []
|
||||||
|
self.resume_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def create_session(self, workspace: Path, title: str) -> str:
|
||||||
|
self.created_sessions.append((workspace, title))
|
||||||
|
return f"{title}-session"
|
||||||
|
|
||||||
|
async def resume(self, **values: Any) -> BaseModel:
|
||||||
|
self.resume_calls.append(values)
|
||||||
|
response = self.responses.pop(0)
|
||||||
|
assert isinstance(response, values["result_type"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job:
|
||||||
|
return Job(
|
||||||
|
id="job-plan",
|
||||||
|
kind=kind,
|
||||||
|
target_key="org/repo:issue:3",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=3,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=5,
|
||||||
|
delivery_id="delivery-plan",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body="/agent plan",
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_workflow(
|
||||||
|
*,
|
||||||
|
runtime: str = "opencode",
|
||||||
|
primary_session_id: str | None = "primary-session",
|
||||||
|
reviewer_session_id: str | None = "reviewer-session",
|
||||||
|
artifact: str | None = "Original plan",
|
||||||
|
) -> Workflow:
|
||||||
|
return Workflow(
|
||||||
|
id="plan-flow",
|
||||||
|
kind=WorkflowKind.PLAN,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=3,
|
||||||
|
workspace_path=Path("/workspace/plan"),
|
||||||
|
base_sha="base-sha",
|
||||||
|
runtime=runtime,
|
||||||
|
primary_session_id=primary_session_id,
|
||||||
|
reviewer_session_id=reviewer_session_id,
|
||||||
|
artifact=artifact,
|
||||||
|
review_json='{"summary":"Prior","findings":[]}',
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def implementation_workflow(pr_number: int | None = 9) -> Workflow:
|
||||||
|
return Workflow(
|
||||||
|
id="implementation-flow",
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=3,
|
||||||
|
workspace_path=Path("/workspace/implementation"),
|
||||||
|
base_sha="base",
|
||||||
|
pr_number=pr_number,
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo:
|
||||||
|
return PullRequestInfo(
|
||||||
|
number=9,
|
||||||
|
title="Agent implementation",
|
||||||
|
body="Body",
|
||||||
|
state=state,
|
||||||
|
merged=merged,
|
||||||
|
base_branch="trunk",
|
||||||
|
head_branch="agent/feature",
|
||||||
|
head_sha="head",
|
||||||
|
head_owner="org",
|
||||||
|
head_repo="repo",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_services(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
latest: Workflow | None = None,
|
||||||
|
implementations: list[Workflow] | None = None,
|
||||||
|
pulls: dict[int, PullRequestInfo] | None = None,
|
||||||
|
responses: list[BaseModel] | None = None,
|
||||||
|
) -> tuple[
|
||||||
|
WorkflowServices,
|
||||||
|
FakeRepository,
|
||||||
|
FakeGitea,
|
||||||
|
RecordingGit,
|
||||||
|
RecordingPrompts,
|
||||||
|
RecordingOpenCode,
|
||||||
|
]:
|
||||||
|
repository = FakeRepository(latest=latest, implementations=implementations)
|
||||||
|
gitea = FakeGitea(pulls)
|
||||||
|
git = RecordingGit()
|
||||||
|
prompts = RecordingPrompts()
|
||||||
|
opencode = RecordingOpenCode(responses)
|
||||||
|
services = cast(
|
||||||
|
WorkflowServices,
|
||||||
|
SimpleNamespace(
|
||||||
|
settings=SimpleNamespace(
|
||||||
|
workspaces_dir=tmp_path / "workspaces",
|
||||||
|
plan_model="provider/model",
|
||||||
|
plan_variant="high",
|
||||||
|
plan_review_rounds=3,
|
||||||
|
),
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
git=git,
|
||||||
|
prompts=prompts,
|
||||||
|
opencode=opencode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return services, repository, gitea, git, prompts, opencode
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_plan_completes_primary_and_independent_review(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
services, repository, gitea, git, prompts, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
responses=[
|
||||||
|
PlanArtifact(plan_markdown="# Complete plan"),
|
||||||
|
ReviewReport(summary="Ready", findings=[]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
body = await create_plan(job(JobKind.PLAN), run, services)
|
||||||
|
|
||||||
|
created, stage = run.created_workflows[0]
|
||||||
|
assert stage == "planning"
|
||||||
|
assert created.kind is WorkflowKind.PLAN
|
||||||
|
assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo"
|
||||||
|
assert git.clone_calls == [("org", "repo", "trunk", created.workspace_path)]
|
||||||
|
assert gitea.default_branch_calls == [("org", "repo")]
|
||||||
|
assert run.linked_sessions == ["plan-session"]
|
||||||
|
assert opencode.created_sessions == [
|
||||||
|
(created.workspace_path, "plan"),
|
||||||
|
(created.workspace_path, "plan-review"),
|
||||||
|
]
|
||||||
|
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||||
|
PlanArtifact,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
assert prompts.calls[0][0] == "plan_initial"
|
||||||
|
assert prompts.calls[0][1]["request"] == "Please be specific."
|
||||||
|
assert "Use the existing API." in prompts.calls[0][1]["context"]
|
||||||
|
assert "Job queued" not in prompts.calls[0][1]["context"]
|
||||||
|
|
||||||
|
completed = repository.saved_workflows[-1]
|
||||||
|
assert completed.status is WorkflowStatus.COMPLETED
|
||||||
|
assert completed.primary_session_id == "plan-session"
|
||||||
|
assert completed.reviewer_session_id == "plan-review-session"
|
||||||
|
assert completed.artifact == "# Complete plan"
|
||||||
|
assert json.loads(completed.review_json or "") == {
|
||||||
|
"summary": "Ready",
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
assert body == f"<!-- agentci:plan workflow={created.id} -->\n# Complete plan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discuss_plan_resumes_primary_session_without_replacing_artifact(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
existing = plan_workflow()
|
||||||
|
services, repository, _, _, prompts, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
latest=existing,
|
||||||
|
responses=[DiscussionReply(markdown="The API remains compatible.")],
|
||||||
|
)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
body = await discuss_plan(job(JobKind.DISCUSS), run, services)
|
||||||
|
|
||||||
|
assert run.linked_workflows == [(existing.id, "discussing")]
|
||||||
|
assert opencode.created_sessions == []
|
||||||
|
assert opencode.resume_calls[0]["session_id"] == "primary-session"
|
||||||
|
assert opencode.resume_calls[0]["schema_name"] == "discussion.json"
|
||||||
|
assert prompts.calls == [
|
||||||
|
(
|
||||||
|
"discuss",
|
||||||
|
{"artifact": "Original plan", "message": "Please be specific."},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert repository.saved_workflows == []
|
||||||
|
assert body == ("<!-- agentci:discussion workflow=plan-flow -->\nThe API remains compatible.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("latest", "message"),
|
||||||
|
[
|
||||||
|
(None, "No completed plan exists. Start with `/agent plan`."),
|
||||||
|
(
|
||||||
|
plan_workflow(runtime="codex"),
|
||||||
|
"The latest plan predates OpenCode and cannot be resumed; start a new `/agent plan`.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
plan_workflow(primary_session_id=None),
|
||||||
|
"The latest plan cannot be resumed; start a new `/agent plan`.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
plan_workflow(artifact=None),
|
||||||
|
"The latest plan cannot be resumed; start a new `/agent plan`.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_discuss_plan_rejects_missing_or_incompatible_plan(
|
||||||
|
tmp_path: Path,
|
||||||
|
latest: Workflow | None,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
services, _, _, _, _, opencode = make_services(tmp_path, latest=latest)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await discuss_plan(job(JobKind.DISCUSS), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert str(error.value) == message
|
||||||
|
assert opencode.resume_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_iterate_plan_revises_then_reuses_reviewer_session(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
existing = plan_workflow()
|
||||||
|
services, repository, gitea, _, prompts, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
latest=existing,
|
||||||
|
implementations=[implementation_workflow(), implementation_workflow(None)],
|
||||||
|
pulls={9: pull(state="closed")},
|
||||||
|
responses=[
|
||||||
|
PlanArtifact(plan_markdown="# Revised plan"),
|
||||||
|
ReviewReport(summary="Ready", findings=[]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
body = await iterate_plan(job(JobKind.ITERATE_PLAN, message=None), run, services)
|
||||||
|
|
||||||
|
assert gitea.pull_calls == [9]
|
||||||
|
assert run.linked_workflows == [(existing.id, "iterating plan")]
|
||||||
|
assert opencode.created_sessions == []
|
||||||
|
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||||
|
"primary-session",
|
||||||
|
"reviewer-session",
|
||||||
|
]
|
||||||
|
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||||
|
PlanArtifact,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
iterate_prompt = prompts.calls[0]
|
||||||
|
assert iterate_prompt[0] == "plan_iterate"
|
||||||
|
assert iterate_prompt[1]["artifact"] == "Original plan"
|
||||||
|
assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)")
|
||||||
|
assert json.loads(iterate_prompt[1]["review"]) == {
|
||||||
|
"summary": "Prior",
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
completed = repository.saved_workflows[-1]
|
||||||
|
assert completed.artifact == "# Revised plan"
|
||||||
|
assert completed.status is WorkflowStatus.COMPLETED
|
||||||
|
assert body == "<!-- agentci:plan workflow=plan-flow -->\n# Revised plan"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("latest", "message"),
|
||||||
|
[
|
||||||
|
(None, "No completed plan exists. Start with `/agent plan`."),
|
||||||
|
(
|
||||||
|
plan_workflow(runtime="codex"),
|
||||||
|
"The latest plan predates OpenCode; start a new plan.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
plan_workflow(primary_session_id=None),
|
||||||
|
"The latest plan is missing resumable sessions; start a new plan.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
plan_workflow(reviewer_session_id=None),
|
||||||
|
"The latest plan is missing resumable sessions; start a new plan.",
|
||||||
|
),
|
||||||
|
(plan_workflow(artifact=None), "The latest plan has no saved artifact."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_iterate_plan_rejects_missing_or_incompatible_plan(
|
||||||
|
tmp_path: Path,
|
||||||
|
latest: Workflow | None,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
services, repository, _, _, _, opencode = make_services(tmp_path, latest=latest)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert str(error.value) == message
|
||||||
|
assert repository.saved_workflows == []
|
||||||
|
assert opencode.resume_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"blocking_pull",
|
||||||
|
[pull(state="open"), pull(state="closed", merged=True)],
|
||||||
|
ids=["open", "merged"],
|
||||||
|
)
|
||||||
|
async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged(
|
||||||
|
tmp_path: Path, blocking_pull: PullRequestInfo
|
||||||
|
) -> None:
|
||||||
|
services, repository, _, _, _, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
latest=plan_workflow(),
|
||||||
|
implementations=[implementation_workflow()],
|
||||||
|
pulls={9: blocking_pull},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert str(error.value) == (
|
||||||
|
"Issue plan iteration is disabled because agent PR #9 is open or merged. "
|
||||||
|
"Iterate an open implementation on its PR."
|
||||||
|
)
|
||||||
|
assert repository.latest_calls == 0
|
||||||
|
assert opencode.resume_calls == []
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||||
|
from agentci.engine.run import JobRun
|
||||||
|
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||||
|
from agentci.workflows.model import AgentResult, ReviewReport
|
||||||
|
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
|
||||||
|
from agentci.workflows.render import JobRejected
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRun(JobRun):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.stages: list[str] = []
|
||||||
|
self.linked_workflows: list[tuple[str, str]] = []
|
||||||
|
self.linked_sessions: list[str] = []
|
||||||
|
|
||||||
|
async def stage(self, stage: str) -> None:
|
||||||
|
self.stages.append(stage)
|
||||||
|
|
||||||
|
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||||
|
self.linked_workflows.append((workflow_id, stage))
|
||||||
|
|
||||||
|
async def link_session(self, session_id: str) -> None:
|
||||||
|
self.linked_sessions.append(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workflow: Workflow | None = None,
|
||||||
|
plan: Workflow | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.workflow = workflow
|
||||||
|
self.plan = plan
|
||||||
|
self.saved_workflows: list[Workflow] = []
|
||||||
|
|
||||||
|
async def workflow_for_pr(self, *_args: object) -> Workflow | None:
|
||||||
|
return self.workflow
|
||||||
|
|
||||||
|
async def latest_workflow(self, *_args: object) -> Workflow | None:
|
||||||
|
return self.plan
|
||||||
|
|
||||||
|
async def operational_comment_ids(self, *_args: object) -> set[int]:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
async def save_workflow(self, workflow: Workflow) -> None:
|
||||||
|
self.workflow = workflow
|
||||||
|
self.saved_workflows.append(workflow)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGitea:
|
||||||
|
def __init__(self, pull: PullRequestInfo) -> None:
|
||||||
|
self.pull = pull
|
||||||
|
self.pull_calls: list[tuple[str, str, int]] = []
|
||||||
|
|
||||||
|
async def pull_request(self, owner: str, repo: str, number: int) -> PullRequestInfo:
|
||||||
|
self.pull_calls.append((owner, repo, number))
|
||||||
|
return self.pull
|
||||||
|
|
||||||
|
async def issue_comments(self, *_args: object) -> list[CommentInfo]:
|
||||||
|
return [CommentInfo(1, "alice", "Please add coverage.", "2026-07-01")]
|
||||||
|
|
||||||
|
async def pull_reviews(self, *_args: object) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"user": {"login": "bob"},
|
||||||
|
"state": "REQUEST_CHANGES",
|
||||||
|
"body": "Handle empty input.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def review_comments(self, *_args: object) -> list[dict[str, Any]]:
|
||||||
|
return [{"path": "src/widget.py", "new_position": 8, "body": "Add a guard."}]
|
||||||
|
|
||||||
|
async def pull_commits(self, *_args: object) -> list[dict[str, Any]]:
|
||||||
|
return [{"sha": "abcdef1234567890", "commit": {"message": "Initial change"}}]
|
||||||
|
|
||||||
|
async def issue(self, *_args: object) -> IssueInfo:
|
||||||
|
return IssueInfo(7, "Fix widget", "The widget is broken.", "open")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingGit:
|
||||||
|
def __init__(self, *, changed: bool = True) -> None:
|
||||||
|
self.changed = changed
|
||||||
|
self.calls: list[tuple[Any, ...]] = []
|
||||||
|
|
||||||
|
async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str:
|
||||||
|
self.calls.append(("clone", owner, repo, branch, destination))
|
||||||
|
return "head-sha"
|
||||||
|
|
||||||
|
async def sync_branch(self, workspace: Path, branch: str) -> str:
|
||||||
|
self.calls.append(("sync_branch", workspace, branch))
|
||||||
|
return "head-sha"
|
||||||
|
|
||||||
|
async def has_changes(self, workspace: Path) -> bool:
|
||||||
|
self.calls.append(("has_changes", workspace))
|
||||||
|
return self.changed
|
||||||
|
|
||||||
|
async def diff_check(self, workspace: Path) -> None:
|
||||||
|
self.calls.append(("diff_check", workspace))
|
||||||
|
|
||||||
|
async def commit(self, workspace: Path, message: str) -> str:
|
||||||
|
self.calls.append(("commit", workspace, message))
|
||||||
|
return "new-sha"
|
||||||
|
|
||||||
|
async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None:
|
||||||
|
self.calls.append(("push", workspace, branch, set_upstream))
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingDevelopment:
|
||||||
|
description = "Python 3.13"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.workspaces: list[Path] = []
|
||||||
|
|
||||||
|
async def prepare(self, workspace: Path) -> None:
|
||||||
|
self.workspaces.append(workspace)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingPrompts:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
||||||
|
def render(self, name: str, **values: str) -> str:
|
||||||
|
self.calls.append((name, values))
|
||||||
|
return f"rendered {name}"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingOpenCode:
|
||||||
|
def __init__(self, responses: list[BaseModel] | None = None) -> None:
|
||||||
|
self.responses = list(responses or [])
|
||||||
|
self.created_sessions: list[tuple[Path, str]] = []
|
||||||
|
self.resume_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def create_session(self, workspace: Path, title: str) -> str:
|
||||||
|
self.created_sessions.append((workspace, title))
|
||||||
|
return f"{title}-session"
|
||||||
|
|
||||||
|
async def resume(self, **values: Any) -> BaseModel:
|
||||||
|
self.resume_calls.append(values)
|
||||||
|
response = self.responses.pop(0)
|
||||||
|
assert isinstance(response, values["result_type"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def job(kind: JobKind, *, pr_number: int | None = 12) -> Job:
|
||||||
|
return Job(
|
||||||
|
id="job-pr",
|
||||||
|
kind=kind,
|
||||||
|
target_key="org/repo:pr:12",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
pr_number=pr_number,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=9,
|
||||||
|
delivery_id="delivery-pr",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body="/agent iterate",
|
||||||
|
message="Handle the review.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def implementation_workflow(
|
||||||
|
*,
|
||||||
|
status: WorkflowStatus = WorkflowStatus.COMPLETED,
|
||||||
|
runtime: str = "opencode",
|
||||||
|
branch: str | None = "agent/issue-7",
|
||||||
|
primary_session_id: str | None = "primary-session",
|
||||||
|
reviewer_session_id: str | None = "reviewer-session",
|
||||||
|
) -> Workflow:
|
||||||
|
return Workflow(
|
||||||
|
id="implementation-flow",
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
workspace_path=Path("/workspace/implementation"),
|
||||||
|
base_sha="base-sha",
|
||||||
|
runtime=runtime,
|
||||||
|
branch=branch,
|
||||||
|
pr_number=12,
|
||||||
|
primary_session_id=primary_session_id,
|
||||||
|
reviewer_session_id=reviewer_session_id,
|
||||||
|
artifact='{"summary_markdown":"Prior","tests":[]}',
|
||||||
|
review_json='{"summary":"Prior review","findings":[]}',
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_workflow() -> Workflow:
|
||||||
|
return Workflow(
|
||||||
|
id="plan-flow",
|
||||||
|
kind=WorkflowKind.PLAN,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=7,
|
||||||
|
workspace_path=Path("/workspace/plan"),
|
||||||
|
base_sha="base",
|
||||||
|
artifact="Canonical plan",
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pull(
|
||||||
|
*,
|
||||||
|
state: str = "open",
|
||||||
|
merged: bool = False,
|
||||||
|
branch: str = "agent/issue-7",
|
||||||
|
) -> PullRequestInfo:
|
||||||
|
return PullRequestInfo(
|
||||||
|
number=12,
|
||||||
|
title="Fix widget",
|
||||||
|
body="Implementation body",
|
||||||
|
state=state,
|
||||||
|
merged=merged,
|
||||||
|
base_branch="main",
|
||||||
|
head_branch=branch,
|
||||||
|
head_sha="abcdef1234567890",
|
||||||
|
head_owner="contributor",
|
||||||
|
head_repo="fork",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_services(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
workflow: Workflow | None = None,
|
||||||
|
pull_info: PullRequestInfo | None = None,
|
||||||
|
plan: Workflow | None = None,
|
||||||
|
changed: bool = True,
|
||||||
|
responses: list[BaseModel] | None = None,
|
||||||
|
) -> tuple[
|
||||||
|
WorkflowServices,
|
||||||
|
FakeRepository,
|
||||||
|
FakeGitea,
|
||||||
|
RecordingGit,
|
||||||
|
RecordingDevelopment,
|
||||||
|
RecordingPrompts,
|
||||||
|
RecordingOpenCode,
|
||||||
|
]:
|
||||||
|
repository = FakeRepository(workflow=workflow, plan=plan)
|
||||||
|
gitea = FakeGitea(pull_info or pull())
|
||||||
|
git = RecordingGit(changed=changed)
|
||||||
|
development = RecordingDevelopment()
|
||||||
|
prompts = RecordingPrompts()
|
||||||
|
opencode = RecordingOpenCode(responses)
|
||||||
|
services = cast(
|
||||||
|
WorkflowServices,
|
||||||
|
SimpleNamespace(
|
||||||
|
settings=SimpleNamespace(
|
||||||
|
workspaces_dir=tmp_path / "workspaces",
|
||||||
|
implement_model="provider/model",
|
||||||
|
implement_variant="high",
|
||||||
|
),
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
git=git,
|
||||||
|
development=development,
|
||||||
|
prompts=prompts,
|
||||||
|
opencode=opencode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return services, repository, gitea, git, development, prompts, opencode
|
||||||
|
|
||||||
|
|
||||||
|
def result(summary: str = "# Refine widget") -> AgentResult:
|
||||||
|
return AgentResult(summary_markdown=summary, tests=["pytest: passed"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_iterate_implementation_reuses_sessions_reviews_commits_and_pushes(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
existing = implementation_workflow()
|
||||||
|
services, repository, _, git, development, prompts, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
workflow=existing,
|
||||||
|
plan=plan_workflow(),
|
||||||
|
responses=[result(), ReviewReport(summary="Ready", findings=[])],
|
||||||
|
)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
body = await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), run, services)
|
||||||
|
|
||||||
|
assert run.linked_workflows == [(existing.id, "synchronizing branch")]
|
||||||
|
assert development.workspaces == [existing.workspace_path]
|
||||||
|
assert opencode.created_sessions == []
|
||||||
|
assert [call["session_id"] for call in opencode.resume_calls] == [
|
||||||
|
"primary-session",
|
||||||
|
"reviewer-session",
|
||||||
|
]
|
||||||
|
assert [call["result_type"] for call in opencode.resume_calls] == [
|
||||||
|
AgentResult,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
iterate_prompt = prompts.calls[0]
|
||||||
|
assert iterate_prompt[0] == "implementation_iterate"
|
||||||
|
assert iterate_prompt[1]["message"] == "Handle the review."
|
||||||
|
assert "Handle empty input." in iterate_prompt[1]["context"]
|
||||||
|
review_prompt = prompts.calls[1]
|
||||||
|
assert review_prompt[0] == "implementation_review"
|
||||||
|
assert review_prompt[1]["artifact"] == "Canonical plan"
|
||||||
|
assert "The widget is broken." in review_prompt[1]["issue_context"]
|
||||||
|
|
||||||
|
assert git.calls == [
|
||||||
|
("sync_branch", existing.workspace_path, "agent/issue-7"),
|
||||||
|
("has_changes", existing.workspace_path),
|
||||||
|
("diff_check", existing.workspace_path),
|
||||||
|
("commit", existing.workspace_path, "agent iterate: Refine widget"),
|
||||||
|
("push", existing.workspace_path, "agent/issue-7", False),
|
||||||
|
]
|
||||||
|
saved = repository.saved_workflows[-1]
|
||||||
|
assert AgentResult.model_validate_json(saved.artifact or "") == result()
|
||||||
|
assert json.loads(saved.review_json or "") == {
|
||||||
|
"summary": "Ready",
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
assert body == (
|
||||||
|
"<!-- agentci:iteration workflow=implementation-flow -->\n"
|
||||||
|
"## Agent result\n\n# Refine widget\n\n"
|
||||||
|
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pr_number", "existing", "message"),
|
||||||
|
[
|
||||||
|
(None, implementation_workflow(), "This command requires a pull request."),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
None,
|
||||||
|
"This is not an open agent-created implementation PR. Use `/agent fix`.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
implementation_workflow(status=WorkflowStatus.ACTIVE),
|
||||||
|
"This is not an open agent-created implementation PR. Use `/agent fix`.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
implementation_workflow(runtime="codex"),
|
||||||
|
"The implementation predates OpenCode and cannot be resumed.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
implementation_workflow(primary_session_id=None),
|
||||||
|
"The implementation sessions cannot be resumed.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
implementation_workflow(reviewer_session_id=None),
|
||||||
|
"The implementation sessions cannot be resumed.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_iterate_implementation_rejects_missing_stale_or_incompatible_workflow(
|
||||||
|
tmp_path: Path,
|
||||||
|
pr_number: int | None,
|
||||||
|
existing: Workflow | None,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
services, repository, gitea, git, development, _, opencode = make_services(
|
||||||
|
tmp_path, workflow=existing
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await iterate_implementation(
|
||||||
|
job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number),
|
||||||
|
RecordingRun(),
|
||||||
|
services,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert str(error.value) == message
|
||||||
|
assert repository.saved_workflows == []
|
||||||
|
assert gitea.pull_calls == []
|
||||||
|
assert git.calls == []
|
||||||
|
assert development.workspaces == []
|
||||||
|
assert opencode.resume_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pull_info", "existing", "message"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
pull(state="closed"),
|
||||||
|
implementation_workflow(),
|
||||||
|
"Implementation iteration requires an open pull request.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
pull(branch="renamed-branch"),
|
||||||
|
implementation_workflow(),
|
||||||
|
"The pull request head branch no longer matches its workflow.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["closed", "branch-mismatch"],
|
||||||
|
)
|
||||||
|
async def test_iterate_implementation_rejects_closed_or_stale_branch_before_checkout(
|
||||||
|
tmp_path: Path,
|
||||||
|
pull_info: PullRequestInfo,
|
||||||
|
existing: Workflow,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
services, repository, _, git, development, _, opencode = make_services(
|
||||||
|
tmp_path, workflow=existing, pull_info=pull_info
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert str(error.value) == message
|
||||||
|
assert repository.saved_workflows == []
|
||||||
|
assert git.calls == []
|
||||||
|
assert development.workspaces == []
|
||||||
|
assert opencode.resume_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
services, repository, _, git, development, prompts, opencode = make_services(
|
||||||
|
tmp_path,
|
||||||
|
responses=[result("Fix empty input")],
|
||||||
|
)
|
||||||
|
run = RecordingRun()
|
||||||
|
|
||||||
|
body = await fix_pull_request(job(JobKind.FIX), run, services)
|
||||||
|
|
||||||
|
workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo"
|
||||||
|
assert git.calls == [
|
||||||
|
("clone", "contributor", "fork", "agent/issue-7", workspace),
|
||||||
|
("has_changes", workspace),
|
||||||
|
("diff_check", workspace),
|
||||||
|
("commit", workspace, "agent fix: Fix empty input"),
|
||||||
|
("push", workspace, "agent/issue-7", False),
|
||||||
|
]
|
||||||
|
assert development.workspaces == [workspace]
|
||||||
|
assert opencode.created_sessions == [(workspace, "fix")]
|
||||||
|
assert run.linked_sessions == ["fix-session"]
|
||||||
|
assert opencode.resume_calls[0]["session_id"] == "fix-session"
|
||||||
|
assert opencode.resume_calls[0]["result_type"] is AgentResult
|
||||||
|
assert prompts.calls[0][0] == "fix"
|
||||||
|
assert "`src/widget.py:8`: Add a guard." in prompts.calls[0][1]["context"]
|
||||||
|
assert repository.saved_workflows == []
|
||||||
|
assert body == (
|
||||||
|
"<!-- agentci:fix workflow=job-pr -->\n"
|
||||||
|
"## Agent result\n\nFix empty input\n\n"
|
||||||
|
"## Validation\n\n- pytest: passed\n\nCommit: `new-sha`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fix_pull_request_rejects_clean_worktree_before_push(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
services, _, _, git, _, _, _ = make_services(
|
||||||
|
tmp_path,
|
||||||
|
changed=False,
|
||||||
|
responses=[result("No changes required")],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
JobRejected,
|
||||||
|
match="OpenCode completed without producing any file changes",
|
||||||
|
):
|
||||||
|
await fix_pull_request(job(JobKind.FIX), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert [call[0] for call in git.calls] == ["clone", "has_changes"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pr_number", "pull_info", "message"),
|
||||||
|
[
|
||||||
|
(None, pull(), "This command requires a pull request."),
|
||||||
|
(12, pull(state="closed"), "Fixes require an open pull request."),
|
||||||
|
(12, pull(state="closed", merged=True), "Fixes require an open pull request."),
|
||||||
|
],
|
||||||
|
ids=["missing-pr", "closed", "merged"],
|
||||||
|
)
|
||||||
|
async def test_fix_pull_request_requires_open_pr_before_clone(
|
||||||
|
tmp_path: Path,
|
||||||
|
pr_number: int | None,
|
||||||
|
pull_info: PullRequestInfo,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
services, _, gitea, git, development, _, opencode = make_services(tmp_path, pull_info=pull_info)
|
||||||
|
|
||||||
|
with pytest.raises(JobRejected) as error:
|
||||||
|
await fix_pull_request(job(JobKind.FIX, pr_number=pr_number), RecordingRun(), services)
|
||||||
|
|
||||||
|
assert str(error.value) == message
|
||||||
|
assert git.calls == []
|
||||||
|
assert development.workspaces == []
|
||||||
|
assert opencode.resume_calls == []
|
||||||
|
assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)])
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.workflows.model import (
|
||||||
|
AgentResult,
|
||||||
|
ReviewFinding,
|
||||||
|
ReviewReport,
|
||||||
|
ReviewSeverity,
|
||||||
|
)
|
||||||
|
from agentci.workflows.render import (
|
||||||
|
agent_comment,
|
||||||
|
commit_title,
|
||||||
|
final_comment,
|
||||||
|
pull_request_body,
|
||||||
|
report_for_prompt,
|
||||||
|
required_session,
|
||||||
|
result_comment,
|
||||||
|
review_markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def report() -> ReviewReport:
|
||||||
|
return ReviewReport(
|
||||||
|
summary="One issue remains.",
|
||||||
|
findings=[
|
||||||
|
ReviewFinding(
|
||||||
|
severity=ReviewSeverity.MAJOR,
|
||||||
|
title="Missing validation",
|
||||||
|
detail="The empty input is not checked.",
|
||||||
|
location="src/widget.py:12",
|
||||||
|
recommendation="Reject empty input.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_markdown_renders_user_visible_finding() -> None:
|
||||||
|
assert review_markdown(report()) == (
|
||||||
|
"## Remaining review findings\n\n"
|
||||||
|
"One issue remains.\n\n"
|
||||||
|
"### MAJOR: Missing validation \u2014 `src/widget.py:12`\n"
|
||||||
|
"The empty input is not checked.\n\n"
|
||||||
|
"Recommendation: Reject empty input."
|
||||||
|
)
|
||||||
|
assert review_markdown(ReviewReport(summary="Ready", findings=[])) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_and_final_comments_preserve_protocol_marker() -> None:
|
||||||
|
assert agent_comment("plan", "flow-1", "Plan body") == (
|
||||||
|
"<!-- agentci:plan workflow=flow-1 -->\nPlan body"
|
||||||
|
)
|
||||||
|
assert final_comment("plan", "flow-1", "Plan body", report()) == (
|
||||||
|
f"<!-- agentci:plan workflow=flow-1 -->\nPlan body\n\n{review_markdown(report())}"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
final_comment("plan", "flow-1", "Plan body", ReviewReport(summary="Ready", findings=[]))
|
||||||
|
== "<!-- agentci:plan workflow=flow-1 -->\nPlan body"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pull_request_body_and_result_comment_render_validation() -> None:
|
||||||
|
result = AgentResult(
|
||||||
|
summary_markdown="Implemented the widget fix.",
|
||||||
|
tests=["pytest: passed", "ruff: passed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pull_request_body(17, result) == (
|
||||||
|
"Closes #17\n\n"
|
||||||
|
"## Implementation\n\nImplemented the widget fix.\n\n"
|
||||||
|
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
|
||||||
|
"_Created by Agent CI._"
|
||||||
|
)
|
||||||
|
assert result_comment(result, sha="abc123") == (
|
||||||
|
"## Agent result\n\nImplemented the widget fix.\n\n"
|
||||||
|
"## Validation\n\n- pytest: passed\n- ruff: passed\n\n"
|
||||||
|
"Commit: `abc123`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderers_report_missing_validation() -> None:
|
||||||
|
result = AgentResult(summary_markdown="Applied the change.", tests=[])
|
||||||
|
|
||||||
|
assert "## Validation\n\n- Not reported" in pull_request_body(2, result)
|
||||||
|
assert result_comment(result) == (
|
||||||
|
"## Agent result\n\nApplied the change.\n\n## Validation\n\n- Not reported"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("markdown", "expected"),
|
||||||
|
[
|
||||||
|
("# Fix widget\n\nDetails", "Fix widget"),
|
||||||
|
("\n## Trim heading \n", "Trim heading"),
|
||||||
|
("\n\t\n", "apply requested changes"),
|
||||||
|
("x" * 80, "x" * 72),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_commit_title_uses_first_content_line(markdown: str, expected: str) -> None:
|
||||||
|
assert commit_title(markdown) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_for_prompt_formats_json_and_preserves_unstructured_text() -> None:
|
||||||
|
stored = '{"summary":"Ready","findings":[]}'
|
||||||
|
|
||||||
|
formatted = report_for_prompt(stored)
|
||||||
|
|
||||||
|
assert json.loads(formatted) == {"summary": "Ready", "findings": []}
|
||||||
|
assert formatted == '{\n "summary": "Ready",\n "findings": []\n}'
|
||||||
|
assert report_for_prompt("plain text review") == "plain text review"
|
||||||
|
assert report_for_prompt(None) == "(none)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_session_returns_value_or_fails_fast() -> None:
|
||||||
|
assert required_session("session-1") == "session-1"
|
||||||
|
with pytest.raises(RuntimeError, match="Expected a persisted OpenCode session ID"):
|
||||||
|
required_session(None)
|
||||||
@@ -19,7 +19,7 @@ dev = [
|
|||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "respx" },
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ dev = [
|
|||||||
{ name = "pyright", specifier = ">=1.1.403" },
|
{ name = "pyright", specifier = ">=1.1.403" },
|
||||||
{ name = "pytest", specifier = ">=8.4,<9" },
|
{ name = "pytest", specifier = ">=8.4,<9" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.1,<2" },
|
{ name = "pytest-asyncio", specifier = ">=1.1,<2" },
|
||||||
{ name = "respx", specifier = ">=0.22,<1" },
|
{ name = "pytest-cov", specifier = ">=6,<8" },
|
||||||
{ name = "ruff", specifier = ">=0.12,<1" },
|
{ name = "ruff", specifier = ">=0.12,<1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -101,6 +101,60 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.15.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.139.2"
|
version = "0.139.2"
|
||||||
@@ -363,6 +417,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -408,18 +476,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "respx"
|
|
||||||
version = "0.23.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.22"
|
version = "0.15.22"
|
||||||
|
|||||||
Reference in New Issue
Block a user