Compare commits
8
Commits
7527831af6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9f1e3d20 | ||
|
|
5ef10d28fe | ||
|
|
73243c1191 | ||
|
|
fb11e1f181 | ||
|
|
d6a0010632 | ||
|
|
4de8c2624a | ||
|
|
0526406472 | ||
|
|
98ac4abca1 |
@@ -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
|
||||||
+1
-2
@@ -1,10 +1,10 @@
|
|||||||
GITEA_NETWORK=gitea
|
GITEA_NETWORK=gitea
|
||||||
|
AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:latest
|
||||||
AGENTCI_GITEA_URL=http://gitea:3000
|
AGENTCI_GITEA_URL=http://gitea:3000
|
||||||
AGENTCI_BOT_USERNAME=agentci
|
AGENTCI_BOT_USERNAME=agentci
|
||||||
AGENTCI_BOT_NAME=Agent CI
|
AGENTCI_BOT_NAME=Agent CI
|
||||||
AGENTCI_BOT_EMAIL=agentci@localhost
|
AGENTCI_BOT_EMAIL=agentci@localhost
|
||||||
OPENCODE_SERVER_USERNAME=opencode
|
OPENCODE_SERVER_USERNAME=opencode
|
||||||
AGENTCI_OPENCODE_VERSION=^1
|
|
||||||
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
|
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
|
||||||
AGENTCI_PLAN_VARIANT=
|
AGENTCI_PLAN_VARIANT=
|
||||||
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
|
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
|
||||||
@@ -24,4 +24,3 @@ AGENTCI_INSTALL_SCRIPTS=
|
|||||||
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
|
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
|
||||||
AGENTCI_PYTHON_VERSION=3.13
|
AGENTCI_PYTHON_VERSION=3.13
|
||||||
AGENTCI_DOTNET_CHANNEL=10.0
|
AGENTCI_DOTNET_CHANNEL=10.0
|
||||||
CODEGRAPH_VERSION=1.3.1
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Publish container image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.krtss.de
|
||||||
|
IMAGE_NAME: git.krtss.de/stanponomarev/agentci
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build and push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
pull: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||||
|
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="~/repos/agentci/.venv" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/agentci.iml" filepath="$PROJECT_DIR$/.idea/agentci.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="PyToolsState">
|
||||||
|
<option name="tools">
|
||||||
|
<map>
|
||||||
|
<entry key="pyright">
|
||||||
|
<value>
|
||||||
|
<ToolEntry>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</ToolEntry>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="ruff">
|
||||||
|
<value>
|
||||||
|
<ToolEntry>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</ToolEntry>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Agent CI repository instructions
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
|
||||||
|
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
|
||||||
|
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
|
||||||
|
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
|
||||||
|
recovery, deployment, or the security boundary.
|
||||||
|
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
|
||||||
|
the requested change merely for consistency.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
|
||||||
|
task claiming, and the `JobRun` event interface.
|
||||||
|
- `src/agentci/application/`: runtime composition, durable control/job queues, task handlers,
|
||||||
|
recovery, and comment reconciliation.
|
||||||
|
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
|
||||||
|
- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
|
||||||
|
- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
|
||||||
|
- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
|
||||||
|
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
|
||||||
|
contracts; keep these concerns outside Python orchestration.
|
||||||
|
- `src/agentci/migrations/`: ordered SQLite migrations.
|
||||||
|
- `tests/`: pytest suite, generally organized by module or behavior.
|
||||||
|
- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and
|
||||||
|
trusted runtime configuration.
|
||||||
|
|
||||||
|
## Setup and commands
|
||||||
|
|
||||||
|
Use Python 3.13 or newer and `uv`. Run commands from the repository root.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the narrowest relevant test while iterating:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run pytest tests/test_<area>.py
|
||||||
|
uv run pytest tests/test_<area>.py::test_<behavior>
|
||||||
|
uv run pytest -k '<expression>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the standard Python checks before completion:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run ruff check .
|
||||||
|
uv run pyright
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage is diagnostic and has no required threshold:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run pytest --cov=agentci --cov-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
For deployment-related changes, also run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
|
||||||
|
OpenCode configuration. It may require network access and takes longer than the normal checks.
|
||||||
|
|
||||||
|
## Engineering conventions
|
||||||
|
|
||||||
|
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
|
||||||
|
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
|
||||||
|
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
|
||||||
|
rather than mutating state in place.
|
||||||
|
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
|
||||||
|
events and task requests.
|
||||||
|
- Apply state transitions, event persistence, and resulting task creation atomically through
|
||||||
|
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
|
||||||
|
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
|
||||||
|
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
|
||||||
|
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
|
||||||
|
deterministic comment reconciliation.
|
||||||
|
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
|
||||||
|
failed and its sessions are aborted rather than replayed.
|
||||||
|
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
|
||||||
|
log credentials, secret contents, authorization headers, or private prompt data.
|
||||||
|
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
|
||||||
|
applied.
|
||||||
|
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
|
||||||
|
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
|
||||||
|
hand.
|
||||||
|
|
||||||
|
## Testing conventions
|
||||||
|
|
||||||
|
- Add regression tests for behavior changes, especially reducer transitions, persistence and
|
||||||
|
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
|
||||||
|
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
|
||||||
|
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
|
||||||
|
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
|
||||||
|
asynchronous.
|
||||||
|
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
|
||||||
|
error text rather than private implementation details.
|
||||||
|
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
|
||||||
|
or network access.
|
||||||
|
|
||||||
|
## Security and operational boundaries
|
||||||
|
|
||||||
|
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime data
|
||||||
|
directories, 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 beyond the documented `./data/agentci` and `./data/opencode` state directories
|
||||||
|
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. `application/worker/` executes those tasks
|
||||||
|
and passes an explicit `JobRun` into the functions under `workflows/`. Modules under
|
||||||
|
`integrations/` own external effects, while `api/` contains the FastAPI host and routes.
|
||||||
|
|
||||||
|
Jobs and workflows are immutable snapshots. Workflows return their final comment body directly;
|
||||||
|
progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table
|
||||||
|
is the authoritative state snapshot, while `job_events` provides durable idempotency and audit data.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Location | Command | Behavior |
|
| Location | Command | Behavior |
|
||||||
@@ -29,26 +46,34 @@ recreated.
|
|||||||
OpenCode models.
|
OpenCode models.
|
||||||
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
|
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
|
||||||
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
|
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
|
||||||
4. Build the image:
|
4. Create the writable state directories for the non-root container user:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose build
|
mkdir -p data/agentci data/opencode
|
||||||
|
sudo chown -R 10001:10001 data/agentci data/opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Authenticate the configured OpenCode providers before starting the persistent server:
|
5. Log in to the Gitea container registry with a personal access token, then pull the image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker login git.krtss.de
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Authenticate the configured OpenCode providers before starting the persistent server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose run --rm opencode opencode auth login
|
docker compose run --rm opencode opencode auth login
|
||||||
docker compose run --rm opencode opencode auth list
|
docker compose run --rm opencode opencode auth list
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Start the services:
|
7. Start the services:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up --no-build -d
|
docker compose up --no-build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
|
8. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
|
||||||
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
|
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
|
||||||
|
|
||||||
OpenCode caches provider state. After adding or changing authentication on an already running
|
OpenCode caches provider state. After adding or changing authentication on an already running
|
||||||
@@ -58,6 +83,22 @@ deployment, run the one-off `auth login` command above and then `docker compose
|
|||||||
healthy and every configured model exists, supports tool calls, accepts its configured variant, and
|
healthy and every configured model exists, supports tool calls, accepts its configured variant, and
|
||||||
has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
|
has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
|
||||||
|
|
||||||
|
### Image publishing
|
||||||
|
|
||||||
|
Every push to `main` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both
|
||||||
|
`git.krtss.de/stanponomarev/agentci:latest` and
|
||||||
|
`git.krtss.de/stanponomarev/agentci:<full-commit-sha>`. Configure these repository Actions secrets
|
||||||
|
before the first run:
|
||||||
|
|
||||||
|
| Secret | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| `REGISTRY_USERNAME` | Username that owns the package or can write packages for the owner. |
|
||||||
|
| `REGISTRY_TOKEN` | Personal access token with package write permission. |
|
||||||
|
|
||||||
|
The automatic Gitea Actions token cannot publish packages. Compose uses `latest` by default; set
|
||||||
|
`AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:<full-commit-sha>` in `.env` to deploy an immutable
|
||||||
|
revision.
|
||||||
|
|
||||||
## OpenCode
|
## OpenCode
|
||||||
|
|
||||||
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
|
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
|
||||||
@@ -67,10 +108,11 @@ configures the research subagent and defaults to `high`. `AGENTCI_EXPLORE_MODEL`
|
|||||||
`AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to
|
`AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to
|
||||||
`openai/gpt-5.6-luna` with `low`.
|
`openai/gpt-5.6-luna` with `low`.
|
||||||
|
|
||||||
`AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults
|
The `AGENTCI_OPENCODE_VERSION` Docker build argument controls the npm version or range installed into
|
||||||
to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker
|
the image and defaults to `^1`. The build verifies that the resolved version is still OpenCode 1.x
|
||||||
may reuse the cached installation layer until the configured version or build inputs change.
|
and prints it. Docker may reuse the cached installation layer until the configured version or build
|
||||||
Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built.
|
inputs change. Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it
|
||||||
|
is built.
|
||||||
|
|
||||||
The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
|
The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
|
||||||
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
|
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
|
||||||
@@ -94,10 +136,11 @@ OpenCode permits still has the Unix-level access of that container user, so envi
|
|||||||
is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command.
|
is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command.
|
||||||
|
|
||||||
Docker remains the OS boundary. The services run as non-root without added capabilities,
|
Docker remains the OS boundary. The services run as non-root without added capabilities,
|
||||||
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only
|
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. Persistent
|
||||||
host bind mount is the read-only installer directory; there are no
|
state is exposed through writable host bind mounts at `./data/agentci` and `./data/opencode`; protect
|
||||||
writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password
|
these directories because they contain private repository clones, installed tools, provider state,
|
||||||
protected, and is reachable by Agent CI over an internal Compose network.
|
and resumable sessions. The OpenCode HTTP server is not published to the host, is password protected,
|
||||||
|
and is reachable by Agent CI over an internal Compose network.
|
||||||
|
|
||||||
## Development environments
|
## Development environments
|
||||||
|
|
||||||
@@ -111,13 +154,13 @@ AGENTCI_PYTHON_VERSION=3.13
|
|||||||
AGENTCI_DOTNET_CHANNEL=10.0
|
AGENTCI_DOTNET_CHANNEL=10.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Every name resolves to a file in `install-scripts/`, mounted read-only at
|
Every name resolves to a file under `/etc/agentci/install-scripts`, copied from `install-scripts/`
|
||||||
`/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers
|
when the image is built. Rebuild the image after adding, replacing, or removing an installer. Names
|
||||||
run after each implementation clone or branch sync and fail the job on an unknown script, timeout,
|
cannot contain paths and duplicates are rejected. Installers run after each implementation clone or
|
||||||
or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain
|
branch sync and fail the job on an unknown script, timeout, or non-zero exit. They receive no Agent CI
|
||||||
trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or
|
or Gitea secret values in their environment, but remain trusted operator code. Tools persist under
|
||||||
modify them through shell commands permitted by its active agent policy. See
|
`./data/agentci/dev-tools`, and OpenCode can read or modify them through shell commands permitted by
|
||||||
`install-scripts/README.md` for the script contract.
|
its active agent policy. See `install-scripts/README.md` for the script contract.
|
||||||
|
|
||||||
Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
|
Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
|
||||||
keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
|
keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
|
||||||
@@ -125,10 +168,10 @@ Git commands itself.
|
|||||||
|
|
||||||
## State and recovery
|
## State and recovery
|
||||||
|
|
||||||
The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes.
|
The `data/agentci` directory contains SQLite, workflow clones, and installed development runtimes.
|
||||||
The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable
|
The `data/opencode` directory contains provider authentication, OpenCode's database, and resumable
|
||||||
sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
|
sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
|
||||||
`opencode_home`. Back up both persistent volumes together.
|
`data/opencode`. Back up both persistent directories together.
|
||||||
|
|
||||||
SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
|
SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
|
||||||
transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
|
transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
|
||||||
@@ -153,9 +196,11 @@ uv sync
|
|||||||
uv run ruff check .
|
uv run ruff check .
|
||||||
uv run pyright
|
uv run pyright
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
uv run pytest --cov=agentci --cov-branch
|
||||||
docker compose config
|
docker compose config
|
||||||
docker compose build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
|
The coverage command is an opt-in diagnostic report; the regular test run remains the default and
|
||||||
Python so orchestration modules remain small and readable.
|
coverage percentage is not used as a pass threshold.
|
||||||
|
|
||||||
|
Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow.
|
||||||
|
|||||||
+5
-16
@@ -1,12 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
agentci:
|
agentci:
|
||||||
image: agentci:local
|
image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}
|
|
||||||
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
|
|
||||||
TEA_VERSION: ${TEA_VERSION:-0.14.2}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
opencode:
|
opencode:
|
||||||
@@ -39,8 +33,7 @@ services:
|
|||||||
- webhook_secret
|
- webhook_secret
|
||||||
- opencode_server_password
|
- opencode_server_password
|
||||||
volumes:
|
volumes:
|
||||||
- agentci_data:/var/lib/agentci
|
- ./data/agentci:/var/lib/agentci
|
||||||
- ./install-scripts:/etc/agentci/install-scripts:ro
|
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /run/agentci:mode=1777
|
- /run/agentci:mode=1777
|
||||||
expose:
|
expose:
|
||||||
@@ -50,7 +43,7 @@ services:
|
|||||||
- agentci_control
|
- agentci_control
|
||||||
|
|
||||||
opencode:
|
opencode:
|
||||||
image: agentci:local
|
image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
|
||||||
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
|
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -81,8 +74,8 @@ services:
|
|||||||
- gitea_token
|
- gitea_token
|
||||||
- opencode_server_password
|
- opencode_server_password
|
||||||
volumes:
|
volumes:
|
||||||
- agentci_data:/var/lib/agentci
|
- ./data/agentci:/var/lib/agentci
|
||||||
- opencode_home:/var/lib/opencode
|
- ./data/opencode:/var/lib/opencode
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /run/agentci:mode=1777
|
- /run/agentci:mode=1777
|
||||||
expose:
|
expose:
|
||||||
@@ -105,10 +98,6 @@ secrets:
|
|||||||
opencode_server_password:
|
opencode_server_password:
|
||||||
file: ./secrets/opencode_server_password
|
file: ./secrets/opencode_server_password
|
||||||
|
|
||||||
volumes:
|
|
||||||
agentci_data:
|
|
||||||
opencode_home:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gitea:
|
gitea:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
This directory supplies the ready-made `python` and `dotnet` scripts. They are not reserved:
|
This directory supplies the ready-made `python` and `dotnet` scripts. They are not reserved:
|
||||||
modify, replace, or remove them like any other script. Place other trusted executable install
|
modify, replace, or remove them like any other script. Place other trusted executable install
|
||||||
scripts here and add the desired file names to `AGENTCI_INSTALL_SCRIPTS`. Compose mounts the
|
scripts here and add the desired file names to `AGENTCI_INSTALL_SCRIPTS`. The Docker build copies
|
||||||
directory read-only at `/etc/agentci/install-scripts`. Executable files run directly; files without
|
this directory to `/etc/agentci/install-scripts`, so rebuild the image after changing its contents.
|
||||||
executable mode run as POSIX shell scripts through `/bin/sh` so bind mounts do not depend on host
|
Executable files run directly; files without executable mode run as POSIX shell scripts through
|
||||||
file-mode preservation.
|
`/bin/sh`.
|
||||||
|
|
||||||
Scripts run from the cloned repository with a sanitized environment. They receive:
|
Scripts run from the cloned repository with a sanitized environment. They receive:
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Scripts run from the cloned repository with a sanitized environment. They receiv
|
|||||||
- `PATH`: `$DEV_TOOLS_DIR/bin` followed by the service path
|
- `PATH`: `$DEV_TOOLS_DIR/bin` followed by the service path
|
||||||
- `PYTHON_VERSION` and `DOTNET_CHANNEL`: configured built-in runtime versions
|
- `PYTHON_VERSION` and `DOTNET_CHANNEL`: configured built-in runtime versions
|
||||||
|
|
||||||
`DEV_TOOLS_DIR` is shared by jobs through the `agentci_data` volume. Put downloaded SDK/runtime
|
`DEV_TOOLS_DIR` is shared by jobs through the `data/agentci` bind mount. Put downloaded SDK/runtime
|
||||||
files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/bin`; that `bin`
|
files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/bin`; that `bin`
|
||||||
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
|
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
|
||||||
not persist into implementation turns.
|
not persist into implementation turns.
|
||||||
|
|||||||
+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"
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from agentci.app import create_app
|
from agentci.api.app import create_app
|
||||||
from agentci.config import Settings
|
from agentci.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -19,4 +19,3 @@ def main() -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 +1 @@
|
|||||||
"""HTTP API."""
|
"""HTTP application and routes."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from agentci.api.errors import register_error_handlers
|
||||||
|
from agentci.api.lifespan import create_lifespan
|
||||||
|
from agentci.api.routes.health import router as health_router
|
||||||
|
from agentci.api.routes.webhook import router as webhook_router
|
||||||
|
from agentci.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
|
selected_settings = settings or Settings()
|
||||||
|
app = FastAPI(
|
||||||
|
title="Agent CI",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=create_lifespan(selected_settings),
|
||||||
|
)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(webhook_router)
|
||||||
|
register_error_handlers(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
|
||||||
|
from agentci.application.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime(request: Request) -> Runtime:
|
||||||
|
return request.app.state.runtime
|
||||||
|
|
||||||
|
|
||||||
|
type RuntimeDependency = Annotated[Runtime, Depends(get_runtime)]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
log.exception(
|
||||||
|
"unhandled HTTP request failure",
|
||||||
|
extra={
|
||||||
|
"operation": "http.request",
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"error_message": str(exc),
|
||||||
|
"status_code": 500,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal server error. See service logs for diagnostics."},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app: FastAPI) -> None:
|
||||||
|
app.exception_handler(Exception)(unhandled_error)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator, Callable
|
||||||
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from agentci.application.runtime import build_runtime
|
||||||
|
from agentci.config.settings import Settings
|
||||||
|
from agentci.observability.logging import configure_logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def create_lifespan(settings: Settings) -> Lifespan:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||||
|
configure_logging()
|
||||||
|
log.info("service startup started", extra={"operation": "service.startup"})
|
||||||
|
try:
|
||||||
|
runtime = await build_runtime(settings)
|
||||||
|
except Exception:
|
||||||
|
log.exception("service startup failed", extra={"operation": "service.startup"})
|
||||||
|
raise
|
||||||
|
app.state.runtime = runtime
|
||||||
|
stop = asyncio.Event()
|
||||||
|
worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
|
||||||
|
try:
|
||||||
|
log.info("service startup completed", extra={"operation": "service.startup"})
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
||||||
|
stop.set()
|
||||||
|
worker_task.cancel()
|
||||||
|
try:
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await worker_task
|
||||||
|
finally:
|
||||||
|
await runtime.close()
|
||||||
|
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
||||||
|
|
||||||
|
return lifespan
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""FastAPI route modules."""
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response, status
|
from fastapi import APIRouter, Response, status
|
||||||
|
|
||||||
|
from agentci.api.dependencies import RuntimeDependency
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -11,8 +13,8 @@ async def live() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/health/ready")
|
@router.get("/health/ready")
|
||||||
async def ready(request: Request, response: Response) -> dict[str, str]:
|
async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]:
|
||||||
if not await request.app.state.container.opencode.ready():
|
if not await runtime.opencode.ready():
|
||||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
return {"status": "not-ready", "reason": "opencode provider is not connected"}
|
return {"status": "not-ready", "reason": "opencode provider is not connected"}
|
||||||
return {"status": "ready"}
|
return {"status": "ready"}
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
from agentci.domain.models import CommandEvent
|
from agentci.api.dependencies import RuntimeDependency
|
||||||
|
from agentci.application.runtime import Runtime
|
||||||
|
from agentci.engine.model import IncomingCommand
|
||||||
|
from agentci.integrations.gitea.webhooks import (
|
||||||
|
SUPPORTED_EVENTS,
|
||||||
|
incoming_command_from_payload,
|
||||||
|
valid_signature,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
SUPPORTED_EVENTS = {
|
|
||||||
"issue_comment",
|
|
||||||
"pull_request_comment",
|
|
||||||
"pull_request_review_comment",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhooks/gitea")
|
@router.post("/webhooks/gitea")
|
||||||
async def webhook(request: Request) -> Response:
|
async def webhook(request: Request, runtime: RuntimeDependency) -> Response:
|
||||||
container = request.app.state.container
|
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
signature = request.headers.get("X-Gitea-Signature", "")
|
signature = request.headers.get("X-Gitea-Signature", "")
|
||||||
if not valid_signature(container.settings.webhook_secret, body, signature):
|
if not valid_signature(runtime.settings.webhook_secret, body, signature):
|
||||||
log.warning(
|
log.warning(
|
||||||
"webhook signature rejected",
|
"webhook signature rejected",
|
||||||
extra={"operation": "webhook.verify", "path": request.url.path},
|
extra={"operation": "webhook.verify", "path": request.url.path},
|
||||||
@@ -41,29 +39,26 @@ async def webhook(request: Request) -> Response:
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
try:
|
try:
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
event = _event_from_payload(
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Webhook payload must be a JSON object")
|
||||||
|
event = incoming_command_from_payload(
|
||||||
request.headers.get("X-Gitea-Delivery", ""),
|
request.headers.get("X-Gitea-Delivery", ""),
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
log.warning(
|
log.warning(
|
||||||
"webhook payload rejected",
|
"webhook payload rejected",
|
||||||
extra={"operation": "webhook.parse", "stage": event_name},
|
extra={"operation": "webhook.parse", "stage": event_name},
|
||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
|
||||||
if event is None or event.requester.casefold() == container.settings.bot_username.casefold():
|
if event is None or event.requester.casefold() == runtime.settings.bot_username.casefold():
|
||||||
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
|
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
return await _handle_command(container, event)
|
return await _handle_command(runtime, event)
|
||||||
|
|
||||||
|
|
||||||
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
|
||||||
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
|
||||||
return bool(signature) and hmac.compare_digest(expected, signature)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_command(container: Any, event: CommandEvent) -> Response:
|
|
||||||
extra = {"operation": "command.handle", "target": event.target_key}
|
extra = {"operation": "command.handle", "target": event.target_key}
|
||||||
if not event.body.strip().startswith("/agent"):
|
if not event.body.strip().startswith("/agent"):
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -71,45 +66,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
|
|||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
|
||||||
log.info("agent command received", extra=extra)
|
log.info("agent command received", extra=extra)
|
||||||
try:
|
try:
|
||||||
result = await container.state_machine.receive(event)
|
result = await runtime.repository.accept(event)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("could not persist command", extra=extra)
|
log.exception("could not persist command", extra=extra)
|
||||||
raise
|
raise
|
||||||
if result.duplicate:
|
if result.duplicate:
|
||||||
log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id})
|
log.info("duplicate command ignored", extra={**extra, "job_id": result.job.id})
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
log.info(
|
log.info(
|
||||||
"agent command persisted",
|
"agent command persisted",
|
||||||
extra={
|
extra={
|
||||||
**extra,
|
**extra,
|
||||||
"job_id": result.state.id,
|
"job_id": result.job.id,
|
||||||
"receive_sequence": result.state.receive_sequence,
|
"receive_sequence": result.job.receive_sequence,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
return Response(status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
|
|
||||||
def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEvent | None:
|
|
||||||
if payload.get("action") != "created":
|
|
||||||
return None
|
|
||||||
comment = payload["comment"]
|
|
||||||
repository = payload["repository"]
|
|
||||||
owner = repository["owner"]
|
|
||||||
owner_name = owner.get("login") or owner.get("username") or owner["name"]
|
|
||||||
pull = payload.get("pull_request")
|
|
||||||
is_pull = bool(payload.get("is_pull") or pull)
|
|
||||||
issue = payload.get("issue")
|
|
||||||
target = pull or issue
|
|
||||||
if target is None:
|
|
||||||
raise ValueError("Comment payload has no issue or pull request")
|
|
||||||
number = int(target["number"])
|
|
||||||
return CommandEvent(
|
|
||||||
delivery_id=delivery_id,
|
|
||||||
comment_id=int(comment["id"]),
|
|
||||||
repo_owner=owner_name,
|
|
||||||
repo_name=repository["name"],
|
|
||||||
issue_number=number,
|
|
||||||
pr_number=number if is_pull else None,
|
|
||||||
requester=comment["user"]["login"],
|
|
||||||
body=comment.get("body") or "",
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
from contextlib import asynccontextmanager, suppress
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from agentci.api.health import router as health_router
|
|
||||||
from agentci.api.webhook import router as webhook_router
|
|
||||||
from agentci.config import Settings
|
|
||||||
from agentci.container import build_container
|
|
||||||
from agentci.logging import configure_logging
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
||||||
selected_settings = settings or Settings()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
||||||
configure_logging()
|
|
||||||
log.info("service startup started", extra={"operation": "service.startup"})
|
|
||||||
try:
|
|
||||||
container = await build_container(selected_settings)
|
|
||||||
except Exception:
|
|
||||||
log.exception("service startup failed", extra={"operation": "service.startup"})
|
|
||||||
raise
|
|
||||||
app.state.container = container
|
|
||||||
stop = asyncio.Event()
|
|
||||||
worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker")
|
|
||||||
try:
|
|
||||||
log.info("service startup completed", extra={"operation": "service.startup"})
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
|
||||||
stop.set()
|
|
||||||
worker_task.cancel()
|
|
||||||
try:
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await worker_task
|
|
||||||
finally:
|
|
||||||
await container.close()
|
|
||||||
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
|
||||||
|
|
||||||
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
|
|
||||||
app.include_router(health_router)
|
|
||||||
app.include_router(webhook_router)
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
|
||||||
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
|
||||||
log.exception(
|
|
||||||
"unhandled HTTP request failure",
|
|
||||||
extra={
|
|
||||||
"operation": "http.request",
|
|
||||||
"method": request.method,
|
|
||||||
"path": request.url.path,
|
|
||||||
"status_code": 500,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"detail": "Internal server error. See service logs for diagnostics."},
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application composition and durable orchestration."""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from agentci.application.worker.runner import Worker
|
||||||
|
from agentci.config.settings import Settings
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.development import DevelopmentEnvironment
|
||||||
|
from agentci.integrations.git import Git
|
||||||
|
from agentci.integrations.gitea.client import Gitea
|
||||||
|
from agentci.integrations.opencode.client import OpenCode
|
||||||
|
from agentci.prompts.library import PromptLibrary
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Runtime:
|
||||||
|
settings: Settings
|
||||||
|
repository: Repository
|
||||||
|
gitea: Gitea
|
||||||
|
git: Git
|
||||||
|
opencode: OpenCode
|
||||||
|
worker: Worker
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
log.info("runtime shutdown started", extra={"operation": "runtime.close"})
|
||||||
|
try:
|
||||||
|
await self.opencode.close()
|
||||||
|
finally:
|
||||||
|
await self.gitea.close()
|
||||||
|
log.info("runtime shutdown completed", extra={"operation": "runtime.close"})
|
||||||
|
|
||||||
|
|
||||||
|
async def build_runtime(settings: Settings) -> Runtime:
|
||||||
|
log.info("runtime initialization started", extra={"operation": "runtime.build"})
|
||||||
|
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
repository = Repository(settings.database_path)
|
||||||
|
await repository.initialize()
|
||||||
|
gitea = Gitea(settings.gitea_url, settings.gitea_token)
|
||||||
|
git = Git(
|
||||||
|
gitea_url=settings.gitea_url,
|
||||||
|
username=settings.bot_username,
|
||||||
|
token=settings.gitea_token,
|
||||||
|
askpass_path=settings.askpass_path,
|
||||||
|
commit_name=settings.bot_name,
|
||||||
|
commit_email=settings.bot_email,
|
||||||
|
)
|
||||||
|
prompts = PromptLibrary()
|
||||||
|
opencode = OpenCode(
|
||||||
|
base_url=settings.opencode_url,
|
||||||
|
username=settings.opencode_server_username,
|
||||||
|
password=settings.opencode_server_password,
|
||||||
|
schemas_dir=prompts.schemas_dir,
|
||||||
|
health_directory=settings.workspaces_dir,
|
||||||
|
required_models=(
|
||||||
|
(settings.plan_model, settings.plan_variant),
|
||||||
|
(settings.implement_model, settings.implement_variant),
|
||||||
|
(settings.explore_model, settings.explore_variant),
|
||||||
|
(settings.research_model, settings.research_variant),
|
||||||
|
),
|
||||||
|
timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
)
|
||||||
|
development = DevelopmentEnvironment(
|
||||||
|
scripts=settings.install_scripts,
|
||||||
|
scripts_dir=settings.install_scripts_dir,
|
||||||
|
tools_dir=settings.dev_tools_dir,
|
||||||
|
timeout_seconds=settings.install_script_timeout_seconds,
|
||||||
|
python_version=settings.python_version,
|
||||||
|
dotnet_channel=settings.dotnet_channel,
|
||||||
|
)
|
||||||
|
services = WorkflowServices(
|
||||||
|
settings=settings,
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
git=git,
|
||||||
|
opencode=opencode,
|
||||||
|
prompts=prompts,
|
||||||
|
development=development,
|
||||||
|
)
|
||||||
|
worker = Worker(
|
||||||
|
repository=repository,
|
||||||
|
gitea=gitea,
|
||||||
|
opencode=opencode,
|
||||||
|
services=services,
|
||||||
|
poll_seconds=settings.worker_poll_seconds,
|
||||||
|
max_concurrent_jobs=settings.max_concurrent_jobs,
|
||||||
|
workspaces_dir=settings.workspaces_dir,
|
||||||
|
bot_username=settings.bot_username,
|
||||||
|
)
|
||||||
|
runtime = Runtime(settings, repository, gitea, git, opencode, worker)
|
||||||
|
log.info("runtime initialization completed", extra={"operation": "runtime.build"})
|
||||||
|
return runtime
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Durable task runner and task handlers."""
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agentci.engine.events import PermissionDenied, PermissionGranted
|
||||||
|
from agentci.engine.model import Job, JobStatus, Task
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.gitea.client import Gitea
|
||||||
|
|
||||||
|
|
||||||
|
async def authorize_job(
|
||||||
|
*, task: Task, job: Job, repository: Repository, gitea: Gitea
|
||||||
|
) -> None:
|
||||||
|
if job.status is not JobStatus.RECEIVED:
|
||||||
|
return
|
||||||
|
permitted = await gitea.has_write_permission(job.repo_owner, job.repo_name, job.requester)
|
||||||
|
event = PermissionGranted(job_id=job.id) if permitted else PermissionDenied(job_id=job.id)
|
||||||
|
outcome = "permission-granted" if permitted else "permission-denied"
|
||||||
|
await repository.apply(f"task:{task.id}:{outcome}", event)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agentci.engine.events import CommentLinked
|
||||||
|
from agentci.engine.model import Job, Task
|
||||||
|
from agentci.engine.reducer import render_job_comment
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.gitea.client import Gitea
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_comment(
|
||||||
|
*,
|
||||||
|
task: Task,
|
||||||
|
job: Job,
|
||||||
|
repository: Repository,
|
||||||
|
gitea: Gitea,
|
||||||
|
bot_username: str,
|
||||||
|
) -> None:
|
||||||
|
latest = await repository.get_job(job.id)
|
||||||
|
if latest is None:
|
||||||
|
return
|
||||||
|
body = render_job_comment(latest)
|
||||||
|
comment_id = latest.accepted_comment_id
|
||||||
|
if comment_id is not None and await gitea.update_comment(
|
||||||
|
latest.repo_owner, latest.repo_name, comment_id, body
|
||||||
|
):
|
||||||
|
return
|
||||||
|
marker = f"<!-- agentci:job id={latest.id} -->"
|
||||||
|
matches = sorted(
|
||||||
|
comment.id
|
||||||
|
for comment in await gitea.issue_comments(
|
||||||
|
latest.repo_owner, latest.repo_name, latest.issue_number
|
||||||
|
)
|
||||||
|
if comment.body.startswith(marker)
|
||||||
|
and comment.author.casefold() == bot_username.casefold()
|
||||||
|
)
|
||||||
|
if matches:
|
||||||
|
comment_id = matches[0]
|
||||||
|
else:
|
||||||
|
comment_id = await gitea.create_comment(
|
||||||
|
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
||||||
|
)
|
||||||
|
await repository.apply(
|
||||||
|
f"task:{task.id}:comment:{comment_id}",
|
||||||
|
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
||||||
|
)
|
||||||
|
await gitea.update_comment(
|
||||||
|
latest.repo_owner, latest.repo_name, comment_id, body
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def safe_error(error: Exception) -> str:
|
||||||
|
message = " ".join(str(error).split())
|
||||||
|
return f"{type(error).__name__}: {message}"[:1000]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agentci.application.worker.errors import safe_error
|
||||||
|
from agentci.engine.events import JobCompleted, JobFailed, JobStarted, ServiceRestarted
|
||||||
|
from agentci.engine.events import JobRejected as RejectedEvent
|
||||||
|
from agentci.engine.model import Job, JobStatus, Task
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.engine.run import JobRun
|
||||||
|
from agentci.workflows.dispatch import dispatch
|
||||||
|
from agentci.workflows.render import JobRejected
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_job(
|
||||||
|
*, task: Task, job: Job, repository: Repository, services: WorkflowServices
|
||||||
|
) -> None:
|
||||||
|
if job.status is JobStatus.RUNNING:
|
||||||
|
await repository.apply(
|
||||||
|
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if job.status is not JobStatus.QUEUED:
|
||||||
|
return
|
||||||
|
result = await repository.apply(f"task:{task.id}:started", JobStarted(job_id=job.id))
|
||||||
|
running = result.job
|
||||||
|
run = JobRun(repository, job.id, task.id)
|
||||||
|
try:
|
||||||
|
body = await dispatch(running, run, services)
|
||||||
|
except JobRejected as exc:
|
||||||
|
await repository.apply(
|
||||||
|
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
latest = await repository.get_job(job.id)
|
||||||
|
stage = latest.stage if latest else running.stage
|
||||||
|
await repository.apply(
|
||||||
|
f"task:{task.id}:failed",
|
||||||
|
JobFailed(job_id=job.id, error=safe_error(exc), stage=stage),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await repository.apply(
|
||||||
|
f"task:{task.id}:completed",
|
||||||
|
JobCompleted(
|
||||||
|
job_id=job.id,
|
||||||
|
comment_body=body or "Agent job completed.",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agentci.engine.events import ServiceRestarted
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
|
||||||
|
async def recover_jobs(repository: Repository) -> None:
|
||||||
|
await repository.recover_tasks()
|
||||||
|
for job in await repository.running_jobs():
|
||||||
|
await repository.apply(
|
||||||
|
f"recovery:{job.id}:service-restarted",
|
||||||
|
ServiceRestarted(job_id=job.id),
|
||||||
|
)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agentci.application.worker.authorization import authorize_job
|
||||||
|
from agentci.application.worker.comments import reconcile_comment
|
||||||
|
from agentci.application.worker.errors import safe_error
|
||||||
|
from agentci.application.worker.execution import execute_job
|
||||||
|
from agentci.application.worker.recovery import recover_jobs
|
||||||
|
from agentci.application.worker.sessions import abort_job_sessions
|
||||||
|
from agentci.engine.model import Job, QueueName, Task, TaskKind
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.gitea.client import Gitea
|
||||||
|
from agentci.integrations.opencode.client import OpenCode
|
||||||
|
from agentci.workflows.services import WorkflowServices
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Worker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
repository: Repository,
|
||||||
|
gitea: Gitea,
|
||||||
|
opencode: OpenCode,
|
||||||
|
services: WorkflowServices,
|
||||||
|
poll_seconds: float,
|
||||||
|
max_concurrent_jobs: int,
|
||||||
|
workspaces_dir: Path,
|
||||||
|
bot_username: str,
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.gitea = gitea
|
||||||
|
self.opencode = opencode
|
||||||
|
self.services = services
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
self.max_concurrent_jobs = max_concurrent_jobs
|
||||||
|
self.workspaces_dir = workspaces_dir
|
||||||
|
self.bot_username = bot_username
|
||||||
|
|
||||||
|
async def run(self, stop: asyncio.Event) -> None:
|
||||||
|
await self._recover()
|
||||||
|
await asyncio.gather(
|
||||||
|
self._loop(QueueName.CONTROL, stop),
|
||||||
|
*(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
|
||||||
|
while not stop.is_set():
|
||||||
|
if queue is QueueName.JOBS and not await self.opencode.ready():
|
||||||
|
await self._wait(stop)
|
||||||
|
continue
|
||||||
|
task = await self.repository.claim_task(queue)
|
||||||
|
if task is None:
|
||||||
|
await self._wait(stop)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self._handle(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception(
|
||||||
|
"listener failed",
|
||||||
|
extra={
|
||||||
|
"task_id": task.id,
|
||||||
|
"listener": task.kind.value,
|
||||||
|
"queue": queue.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.repository.retry_task(task.id, task.attempts, safe_error(exc))
|
||||||
|
else:
|
||||||
|
await self.repository.complete_task(task.id)
|
||||||
|
|
||||||
|
async def _handle(self, task: Task) -> None:
|
||||||
|
job = await self.repository.get_job(task.job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
match task.kind:
|
||||||
|
case TaskKind.AUTHORIZE:
|
||||||
|
await self._authorize(task, job)
|
||||||
|
case TaskKind.EXECUTE:
|
||||||
|
await self._execute(task, job)
|
||||||
|
case TaskKind.RECONCILE_COMMENT:
|
||||||
|
await self._reconcile(task, job)
|
||||||
|
case TaskKind.FAIL_WORKFLOW:
|
||||||
|
await self.repository.fail_job_workflow(job.id)
|
||||||
|
case TaskKind.ABORT_SESSIONS:
|
||||||
|
await self._abort_job_sessions(job)
|
||||||
|
case _:
|
||||||
|
raise RuntimeError(f"Unknown task kind {task.kind}")
|
||||||
|
|
||||||
|
async def _authorize(self, task: Task, job: Job) -> None:
|
||||||
|
await authorize_job(
|
||||||
|
task=task,
|
||||||
|
job=job,
|
||||||
|
repository=self.repository,
|
||||||
|
gitea=self.gitea,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute(self, task: Task, job: Job) -> None:
|
||||||
|
await execute_job(
|
||||||
|
task=task,
|
||||||
|
job=job,
|
||||||
|
repository=self.repository,
|
||||||
|
services=self.services,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _reconcile(self, task: Task, job: Job) -> None:
|
||||||
|
await reconcile_comment(
|
||||||
|
task=task,
|
||||||
|
job=job,
|
||||||
|
repository=self.repository,
|
||||||
|
gitea=self.gitea,
|
||||||
|
bot_username=self.bot_username,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _recover(self) -> None:
|
||||||
|
await recover_jobs(self.repository)
|
||||||
|
|
||||||
|
async def _abort_job_sessions(self, job: Job) -> None:
|
||||||
|
await abort_job_sessions(
|
||||||
|
job=job,
|
||||||
|
repository=self.repository,
|
||||||
|
opencode=self.opencode,
|
||||||
|
workspaces_dir=self.workspaces_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _wait(self, stop: asyncio.Event) -> None:
|
||||||
|
with suppress(TimeoutError):
|
||||||
|
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agentci.engine.model import Job
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.opencode.client import OpenCode
|
||||||
|
|
||||||
|
|
||||||
|
async def abort_job_sessions(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
repository: Repository,
|
||||||
|
opencode: OpenCode,
|
||||||
|
workspaces_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
sessions: set[tuple[str, Path]] = set()
|
||||||
|
workflow = await repository.get_workflow(job.workflow_id) if job.workflow_id else None
|
||||||
|
if workflow:
|
||||||
|
sessions.update(
|
||||||
|
(session, workflow.workspace_path)
|
||||||
|
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
|
||||||
|
if session
|
||||||
|
)
|
||||||
|
elif job.runtime_session_id:
|
||||||
|
sessions.add(
|
||||||
|
(job.runtime_session_id, workspaces_dir / f"fix-{job.id}" / "repo")
|
||||||
|
)
|
||||||
|
for session, workspace in sessions:
|
||||||
|
await opencode.abort(session, workspace)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Service configuration."""
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from agentci.adapters.development import DevelopmentEnvironment
|
|
||||||
from agentci.adapters.git import GitClient
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
|
||||||
from agentci.adapters.opencode import OpenCodeClient
|
|
||||||
from agentci.adapters.storage import Storage
|
|
||||||
from agentci.config import Settings
|
|
||||||
from agentci.prompts import PromptLibrary
|
|
||||||
from agentci.state_machine import StateMachine
|
|
||||||
from agentci.worker import Worker
|
|
||||||
from agentci.workflows.common import Dependencies
|
|
||||||
from agentci.workflows.context import ContextBuilder
|
|
||||||
from agentci.workflows.dispatcher import Dispatcher
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Container:
|
|
||||||
settings: Settings
|
|
||||||
storage: Storage
|
|
||||||
gitea: GiteaClient
|
|
||||||
git: GitClient
|
|
||||||
opencode: OpenCodeClient
|
|
||||||
state_machine: StateMachine
|
|
||||||
worker: Worker
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
log.info("container shutdown started", extra={"operation": "container.close"})
|
|
||||||
await self.opencode.close()
|
|
||||||
await self.gitea.close()
|
|
||||||
log.info("container shutdown completed", extra={"operation": "container.close"})
|
|
||||||
|
|
||||||
|
|
||||||
async def build_container(settings: Settings) -> Container:
|
|
||||||
log.info("container initialization started", extra={"operation": "container.build"})
|
|
||||||
package_dir = Path(__file__).parent
|
|
||||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
storage = Storage(settings.database_path, package_dir / "migrations")
|
|
||||||
await storage.initialize()
|
|
||||||
state_machine = StateMachine(storage)
|
|
||||||
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
|
|
||||||
git = GitClient(
|
|
||||||
gitea_url=settings.gitea_url,
|
|
||||||
username=settings.bot_username,
|
|
||||||
token=settings.gitea_token,
|
|
||||||
askpass_path=settings.askpass_path,
|
|
||||||
commit_name=settings.bot_name,
|
|
||||||
commit_email=settings.bot_email,
|
|
||||||
)
|
|
||||||
opencode = OpenCodeClient(
|
|
||||||
base_url=settings.opencode_url,
|
|
||||||
username=settings.opencode_server_username,
|
|
||||||
password=settings.opencode_server_password,
|
|
||||||
schemas_dir=package_dir / "prompts" / "schemas",
|
|
||||||
health_directory=settings.workspaces_dir,
|
|
||||||
required_models=(
|
|
||||||
(settings.plan_model, settings.plan_variant),
|
|
||||||
(settings.implement_model, settings.implement_variant),
|
|
||||||
(settings.explore_model, settings.explore_variant),
|
|
||||||
(settings.research_model, settings.research_variant),
|
|
||||||
),
|
|
||||||
timeout_seconds=settings.turn_timeout_seconds,
|
|
||||||
)
|
|
||||||
prompts = PromptLibrary()
|
|
||||||
context = ContextBuilder(gitea, storage)
|
|
||||||
development = DevelopmentEnvironment(
|
|
||||||
scripts=settings.install_scripts,
|
|
||||||
scripts_dir=settings.install_scripts_dir,
|
|
||||||
tools_dir=settings.dev_tools_dir,
|
|
||||||
timeout_seconds=settings.install_script_timeout_seconds,
|
|
||||||
python_version=settings.python_version,
|
|
||||||
dotnet_channel=settings.dotnet_channel,
|
|
||||||
)
|
|
||||||
dependencies = Dependencies(
|
|
||||||
settings=settings,
|
|
||||||
storage=storage,
|
|
||||||
gitea=gitea,
|
|
||||||
git=git,
|
|
||||||
opencode=opencode,
|
|
||||||
prompts=prompts,
|
|
||||||
context=context,
|
|
||||||
development=development,
|
|
||||||
)
|
|
||||||
dispatcher = Dispatcher(dependencies)
|
|
||||||
worker = Worker(
|
|
||||||
storage=storage,
|
|
||||||
state_machine=state_machine,
|
|
||||||
gitea=gitea,
|
|
||||||
opencode=opencode,
|
|
||||||
dispatcher=dispatcher,
|
|
||||||
poll_seconds=settings.worker_poll_seconds,
|
|
||||||
max_concurrent_jobs=settings.max_concurrent_jobs,
|
|
||||||
workspaces_dir=settings.workspaces_dir,
|
|
||||||
bot_username=settings.bot_username,
|
|
||||||
)
|
|
||||||
container = Container(settings, storage, gitea, git, opencode, state_machine, worker)
|
|
||||||
log.info("container initialization completed", extra={"operation": "container.build"})
|
|
||||||
return container
|
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""External service and process integrations."""
|
||||||
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CodeGraphClient:
|
class CodeGraph:
|
||||||
async def prepare(self, workspace: Path) -> None:
|
async def prepare(self, workspace: Path) -> None:
|
||||||
self._exclude_index(workspace)
|
self._exclude_index(workspace)
|
||||||
index = workspace / ".codegraph" / "codegraph.db"
|
index = workspace / ".codegraph" / "codegraph.db"
|
||||||
@@ -13,7 +13,7 @@ class GitError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GitClient:
|
class Git:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -31,9 +31,6 @@ class GitClient:
|
|||||||
self.commit_name = commit_name
|
self.commit_name = commit_name
|
||||||
self.commit_email = commit_email
|
self.commit_email = commit_email
|
||||||
|
|
||||||
def clone_url(self, owner: str, repo: str) -> str:
|
|
||||||
return f"{self.gitea_url}/{owner}/{repo}.git"
|
|
||||||
|
|
||||||
async def clone(
|
async def clone(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
@@ -47,7 +44,7 @@ class GitClient:
|
|||||||
"--branch",
|
"--branch",
|
||||||
branch,
|
branch,
|
||||||
"--single-branch",
|
"--single-branch",
|
||||||
self.clone_url(owner, repo),
|
f"{self.gitea_url}/{owner}/{repo}.git",
|
||||||
str(destination),
|
str(destination),
|
||||||
cwd=destination.parent,
|
cwd=destination.parent,
|
||||||
authenticated=True,
|
authenticated=True,
|
||||||
@@ -85,6 +82,7 @@ class GitClient:
|
|||||||
"-m",
|
"-m",
|
||||||
message,
|
message,
|
||||||
cwd=workspace,
|
cwd=workspace,
|
||||||
|
command_name="commit",
|
||||||
)
|
)
|
||||||
return await self.current_sha(workspace)
|
return await self.current_sha(workspace)
|
||||||
|
|
||||||
@@ -101,8 +99,10 @@ class GitClient:
|
|||||||
*args: str,
|
*args: str,
|
||||||
cwd: Path,
|
cwd: Path,
|
||||||
authenticated: bool = False,
|
authenticated: bool = False,
|
||||||
|
command_name: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
operation = f"git.{args[0]}"
|
command_name = command_name or args[0]
|
||||||
|
operation = f"git.{command_name}"
|
||||||
started = monotonic()
|
started = monotonic()
|
||||||
log.info("git step started", extra={"operation": operation})
|
log.info("git step started", extra={"operation": operation})
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
@@ -130,14 +130,14 @@ class GitClient:
|
|||||||
"git step could not start",
|
"git step could not start",
|
||||||
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
||||||
)
|
)
|
||||||
raise GitError(f"Could not run git {args[0]}: {exc}") from exc
|
raise GitError(f"Could not run git {command_name}: {exc}") from exc
|
||||||
if process.returncode:
|
if process.returncode:
|
||||||
detail = stderr.decode(errors="replace").strip()
|
detail = stderr.decode(errors="replace").strip()
|
||||||
log.error(
|
log.error(
|
||||||
"git step failed",
|
"git step failed",
|
||||||
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
||||||
)
|
)
|
||||||
raise GitError(f"git {args[0]} failed: {detail[-1000:]}")
|
raise GitError(f"git {command_name} failed: {detail[-1000:]}")
|
||||||
log.info(
|
log.info(
|
||||||
"git step completed",
|
"git step completed",
|
||||||
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Gitea API and webhook integration."""
|
||||||
@@ -7,12 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from agentci.adapters.gitea_models import (
|
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||||
CommentInfo,
|
|
||||||
IssueInfo,
|
|
||||||
PullRequestInfo,
|
|
||||||
RepositoryInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,14 +16,22 @@ class GiteaError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class Gitea:
|
||||||
def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
retries: int = 3,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.retries = retries
|
self.retries = retries
|
||||||
self.client = httpx.AsyncClient(
|
self.client = httpx.AsyncClient(
|
||||||
base_url=f"{self.base_url}/api/v1",
|
base_url=f"{self.base_url}/api/v1",
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
transport=transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
@@ -41,14 +44,9 @@ class GiteaClient:
|
|||||||
permission = str(response.json().get("permission", "")).lower()
|
permission = str(response.json().get("permission", "")).lower()
|
||||||
return permission in {"write", "admin", "owner"}
|
return permission in {"write", "admin", "owner"}
|
||||||
|
|
||||||
async def repository(self, owner: str, repo: str) -> RepositoryInfo:
|
async def default_branch(self, owner: str, repo: str) -> str:
|
||||||
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
|
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
|
||||||
return RepositoryInfo(
|
return str(data["default_branch"])
|
||||||
owner=owner,
|
|
||||||
name=repo,
|
|
||||||
full_name=data.get("full_name", f"{owner}/{repo}"),
|
|
||||||
default_branch=data["default_branch"],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
|
async def issue(self, owner: str, repo: str, number: int) -> IssueInfo:
|
||||||
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
|
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
|
||||||
@@ -3,14 +3,6 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RepositoryInfo:
|
|
||||||
owner: str
|
|
||||||
name: str
|
|
||||||
full_name: str
|
|
||||||
default_branch: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class IssueInfo:
|
class IssueInfo:
|
||||||
number: int
|
number: int
|
||||||
@@ -43,4 +35,3 @@ class PullRequestInfo:
|
|||||||
@property
|
@property
|
||||||
def is_open(self) -> bool:
|
def is_open(self) -> bool:
|
||||||
return self.state == "open" and not self.merged
|
return self.state == "open" and not self.merged
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agentci.engine.model import IncomingCommand
|
||||||
|
|
||||||
|
SUPPORTED_EVENTS = {
|
||||||
|
"issue_comment",
|
||||||
|
"pull_request_comment",
|
||||||
|
"pull_request_review_comment",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
||||||
|
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||||
|
return bool(signature) and hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def incoming_command_from_payload(
|
||||||
|
delivery_id: str, payload: dict[str, Any]
|
||||||
|
) -> IncomingCommand | None:
|
||||||
|
if payload.get("action") != "created":
|
||||||
|
return None
|
||||||
|
comment = payload["comment"]
|
||||||
|
repository = payload["repository"]
|
||||||
|
owner = repository["owner"]
|
||||||
|
owner_name = owner.get("login") or owner.get("username") or owner["name"]
|
||||||
|
pull = payload.get("pull_request")
|
||||||
|
is_pull = bool(payload.get("is_pull") or pull)
|
||||||
|
issue = payload.get("issue")
|
||||||
|
target = pull or issue
|
||||||
|
if target is None:
|
||||||
|
raise ValueError("Comment payload has no issue or pull request")
|
||||||
|
number = int(target["number"])
|
||||||
|
return IncomingCommand(
|
||||||
|
delivery_id=delivery_id,
|
||||||
|
comment_id=int(comment["id"]),
|
||||||
|
repo_owner=owner_name,
|
||||||
|
repo_name=repository["name"],
|
||||||
|
issue_number=number,
|
||||||
|
pr_number=number if is_pull else None,
|
||||||
|
requester=comment["user"]["login"],
|
||||||
|
body=comment.get("body") or "",
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""OpenCode API integration."""
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
@@ -9,16 +10,9 @@ from typing import Any, TypeVar
|
|||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from agentci.adapters.codegraph import CodeGraphClient
|
from agentci.integrations.codegraph import CodeGraph
|
||||||
from agentci.adapters.opencode_support import (
|
from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
|
||||||
api_contract_ready,
|
from agentci.integrations.opencode.schemas import load_schema
|
||||||
directory_headers,
|
|
||||||
elapsed_ms,
|
|
||||||
error_message,
|
|
||||||
load_schema,
|
|
||||||
model_parts,
|
|
||||||
models_ready,
|
|
||||||
)
|
|
||||||
|
|
||||||
T = TypeVar("T", bound=BaseModel)
|
T = TypeVar("T", bound=BaseModel)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -28,7 +22,7 @@ class OpenCodeError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OpenCodeClient:
|
class OpenCode:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -39,7 +33,7 @@ class OpenCodeClient:
|
|||||||
health_directory: Path,
|
health_directory: Path,
|
||||||
required_models: tuple[tuple[str, str | None], ...],
|
required_models: tuple[tuple[str, str | None], ...],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
codegraph: CodeGraphClient | None = None,
|
codegraph: CodeGraph | None = None,
|
||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.schemas_dir = schemas_dir
|
self.schemas_dir = schemas_dir
|
||||||
@@ -48,8 +42,9 @@ class OpenCodeClient:
|
|||||||
(*model_parts(model), variant) for model, variant in required_models
|
(*model_parts(model), variant) for model, variant in required_models
|
||||||
}
|
}
|
||||||
self._contract_valid: bool | None = None
|
self._contract_valid: bool | None = None
|
||||||
|
self._readiness_task: asyncio.Task[bool] | None = None
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
self.codegraph = codegraph or CodeGraphClient()
|
self.codegraph = codegraph or CodeGraph()
|
||||||
self._active_sessions: dict[str, Path] = {}
|
self._active_sessions: dict[str, Path] = {}
|
||||||
self.client = httpx.AsyncClient(
|
self.client = httpx.AsyncClient(
|
||||||
base_url=base_url.rstrip("/"),
|
base_url=base_url.rstrip("/"),
|
||||||
@@ -59,11 +54,27 @@ class OpenCodeClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
if self._readiness_task is not None and not self._readiness_task.done():
|
||||||
|
self._readiness_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await self._readiness_task
|
||||||
|
self._readiness_task = None
|
||||||
for session_id, workspace in tuple(self._active_sessions.items()):
|
for session_id, workspace in tuple(self._active_sessions.items()):
|
||||||
await self.abort(session_id, workspace, best_effort=True)
|
await self.abort(session_id, workspace, best_effort=True)
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|
||||||
async def ready(self) -> bool:
|
async def ready(self) -> bool:
|
||||||
|
task = self._readiness_task
|
||||||
|
if task is None:
|
||||||
|
task = asyncio.create_task(self._check_ready())
|
||||||
|
self._readiness_task = task
|
||||||
|
try:
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
finally:
|
||||||
|
if task.done() and self._readiness_task is task:
|
||||||
|
self._readiness_task = None
|
||||||
|
|
||||||
|
async def _check_ready(self) -> bool:
|
||||||
try:
|
try:
|
||||||
health = await self.client.get("/global/health", timeout=10)
|
health = await self.client.get("/global/health", timeout=10)
|
||||||
health.raise_for_status()
|
health.raise_for_status()
|
||||||
@@ -85,28 +96,6 @@ class OpenCodeClient:
|
|||||||
except (httpx.HTTPError, TypeError, ValueError):
|
except (httpx.HTTPError, TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
workspace: Path,
|
|
||||||
prompt: str,
|
|
||||||
model: str,
|
|
||||||
variant: str | None,
|
|
||||||
schema_name: str,
|
|
||||||
result_type: type[T],
|
|
||||||
) -> tuple[str, T]:
|
|
||||||
session_id = await self.create_session(workspace, schema_name)
|
|
||||||
result = await self.resume(
|
|
||||||
session_id=session_id,
|
|
||||||
workspace=workspace,
|
|
||||||
prompt=prompt,
|
|
||||||
model=model,
|
|
||||||
variant=variant,
|
|
||||||
schema_name=schema_name,
|
|
||||||
result_type=result_type,
|
|
||||||
)
|
|
||||||
return session_id, result
|
|
||||||
|
|
||||||
async def create_session(self, workspace: Path, title: str) -> str:
|
async def create_session(self, workspace: Path, title: str) -> str:
|
||||||
response = await self._request(
|
response = await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -143,7 +132,10 @@ class OpenCodeClient:
|
|||||||
result_type=result_type,
|
result_type=result_type,
|
||||||
)
|
)
|
||||||
except (asyncio.CancelledError, OpenCodeError):
|
except (asyncio.CancelledError, OpenCodeError):
|
||||||
|
try:
|
||||||
await asyncio.shield(self.abort(session_id, workspace))
|
await asyncio.shield(self.abort(session_id, workspace))
|
||||||
|
except OpenCodeError as exc:
|
||||||
|
log.warning("OpenCode failed session could not be aborted", exc_info=exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
self._active_sessions.pop(session_id, None)
|
self._active_sessions.pop(session_id, None)
|
||||||
@@ -186,7 +178,6 @@ class OpenCodeClient:
|
|||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
await self.abort(session_id, workspace)
|
|
||||||
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
|
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
|
||||||
raise OpenCodeError(message) from exc
|
raise OpenCodeError(message) from exc
|
||||||
info = response.get("info")
|
info = response.get("info")
|
||||||
@@ -213,7 +204,10 @@ class OpenCodeClient:
|
|||||||
raise OpenCodeError("OpenCode did not return a valid result")
|
raise OpenCodeError("OpenCode did not return a valid result")
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self, method: str, path: str, *,
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
json: dict[str, Any] | None = None,
|
json: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -248,3 +242,27 @@ class OpenCodeClient:
|
|||||||
log.warning("OpenCode session could not be aborted", exc_info=exc)
|
log.warning("OpenCode session could not be aborted", exc_info=exc)
|
||||||
return
|
return
|
||||||
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
|
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def model_parts(model: str) -> tuple[str, str]:
|
||||||
|
provider, separator, model_id = model.partition("/")
|
||||||
|
if not separator or not provider or not model_id:
|
||||||
|
raise ValueError(f"OpenCode model must use provider/model format: {model}")
|
||||||
|
return provider, model_id
|
||||||
|
|
||||||
|
|
||||||
|
def error_message(error: object) -> str | None:
|
||||||
|
if not error:
|
||||||
|
return None
|
||||||
|
if isinstance(error, dict):
|
||||||
|
return str(error.get("name") or error.get("message") or error)
|
||||||
|
return str(error)
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_ms(started: float) -> int:
|
||||||
|
return round((monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def directory_headers(workspace: Path) -> dict[str, str]:
|
||||||
|
return {"X-Opencode-Directory": str(workspace.resolve())}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def api_contract_ready(document: object) -> bool:
|
||||||
|
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
|
||||||
|
return False
|
||||||
|
paths = document["paths"]
|
||||||
|
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
|
||||||
|
for path, method in fixed.items():
|
||||||
|
operations = paths.get(path)
|
||||||
|
if not isinstance(operations, dict) or method not in operations:
|
||||||
|
return False
|
||||||
|
session_paths = [
|
||||||
|
path
|
||||||
|
for path, operations in paths.items()
|
||||||
|
if isinstance(path, str) and isinstance(operations, dict) and path.startswith("/session/{")
|
||||||
|
]
|
||||||
|
has_message = any(path.endswith("/message") and "post" in paths[path] for path in session_paths)
|
||||||
|
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
|
||||||
|
return has_message and has_abort
|
||||||
|
|
||||||
|
|
||||||
|
def models_ready(payload: object, requirements: set[tuple[str, str, str | None]]) -> bool:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
connected_value = payload.get("connected")
|
||||||
|
provider_values = payload.get("all")
|
||||||
|
if not isinstance(connected_value, list) or not all(
|
||||||
|
isinstance(item, str) for item in connected_value
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if not isinstance(provider_values, list):
|
||||||
|
return False
|
||||||
|
connected = set(connected_value)
|
||||||
|
providers: dict[str, dict[str, Any]] = {}
|
||||||
|
for item in provider_values:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
provider_id = item.get("id")
|
||||||
|
if isinstance(provider_id, str) and isinstance(item.get("models"), dict):
|
||||||
|
providers[provider_id] = item
|
||||||
|
for provider_id, model_id, variant in requirements:
|
||||||
|
provider = providers.get(provider_id)
|
||||||
|
if provider_id not in connected or not isinstance(provider, dict):
|
||||||
|
return False
|
||||||
|
model: Any = provider["models"].get(model_id)
|
||||||
|
if not isinstance(model, dict) or model.get("status") == "deprecated":
|
||||||
|
return False
|
||||||
|
capabilities = model.get("capabilities")
|
||||||
|
if not isinstance(capabilities, dict) or capabilities.get("toolcall") is not True:
|
||||||
|
return False
|
||||||
|
if variant:
|
||||||
|
variants = model.get("variants")
|
||||||
|
if not isinstance(variants, dict) or variant not in variants:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads((schemas_dir / name).read_text())
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"Result schema {name} is not a JSON object")
|
||||||
|
return value
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Service observability configuration."""
|
||||||
@@ -15,6 +15,7 @@ LOG_FIELDS = (
|
|||||||
"method",
|
"method",
|
||||||
"path",
|
"path",
|
||||||
"status_code",
|
"status_code",
|
||||||
|
"error_message",
|
||||||
"attempt",
|
"attempt",
|
||||||
"item_count",
|
"item_count",
|
||||||
"duration_ms",
|
"duration_ms",
|
||||||
@@ -37,9 +38,14 @@ class JsonFormatter(logging.Formatter):
|
|||||||
try:
|
try:
|
||||||
return json.dumps(payload, ensure_ascii=False)
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
payload["message"] = "Log record could not be serialized"
|
fallback = {
|
||||||
payload["exception"] = traceback.format_exc()
|
"timestamp": payload["timestamp"],
|
||||||
return json.dumps(payload, ensure_ascii=False)
|
"level": payload["level"],
|
||||||
|
"logger": payload["logger"],
|
||||||
|
"message": "Log record could not be serialized",
|
||||||
|
"exception": traceback.format_exc(),
|
||||||
|
}
|
||||||
|
return json.dumps(fallback, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
@@ -1,14 +1 @@
|
|||||||
from __future__ import annotations
|
"""Prompt templates and structured-output contracts."""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from string import Template
|
|
||||||
|
|
||||||
|
|
||||||
class PromptLibrary:
|
|
||||||
def __init__(self, directory: Path | None = None) -> None:
|
|
||||||
self.directory = directory or Path(__file__).parent
|
|
||||||
|
|
||||||
def render(self, name: str, **values: str) -> str:
|
|
||||||
template = Template((self.directory / f"{name}.md").read_text())
|
|
||||||
return template.substitute(values)
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
|
||||||
|
class PromptLibrary:
|
||||||
|
def __init__(self, directory: Path | None = None) -> None:
|
||||||
|
self.directory = directory or Path(__file__).parent
|
||||||
|
|
||||||
|
def render(self, name: str, **values: str) -> str:
|
||||||
|
template = Template((self.directory / f"{name}.md").read_text())
|
||||||
|
return template.substitute(values)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schemas_dir(self) -> Path:
|
||||||
|
return self.directory / "schemas"
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
|
||||||
from agentci.adapters.job_store import ListenerTask
|
|
||||||
from agentci.adapters.opencode import OpenCodeClient
|
|
||||||
from agentci.adapters.storage import Storage
|
|
||||||
from agentci.domain.events import (
|
|
||||||
CommentLinked,
|
|
||||||
JobCompleted,
|
|
||||||
JobFailed,
|
|
||||||
JobStarted,
|
|
||||||
PermissionDenied,
|
|
||||||
PermissionGranted,
|
|
||||||
ServiceRestarted,
|
|
||||||
)
|
|
||||||
from agentci.domain.events import (
|
|
||||||
JobRejected as RejectedEvent,
|
|
||||||
)
|
|
||||||
from agentci.domain.models import JobStatus
|
|
||||||
from agentci.domain.state_machine import JobState, render_job_comment
|
|
||||||
from agentci.reporting import JobReporter, bind_reporter, reset_reporter
|
|
||||||
from agentci.state_machine import StateMachine
|
|
||||||
from agentci.workflows.common import JobRejected
|
|
||||||
from agentci.workflows.dispatcher import Dispatcher
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Worker:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
storage: Storage,
|
|
||||||
state_machine: StateMachine,
|
|
||||||
gitea: GiteaClient,
|
|
||||||
opencode: OpenCodeClient,
|
|
||||||
dispatcher: Dispatcher,
|
|
||||||
poll_seconds: float,
|
|
||||||
max_concurrent_jobs: int,
|
|
||||||
workspaces_dir: Path,
|
|
||||||
bot_username: str,
|
|
||||||
) -> None:
|
|
||||||
self.storage = storage
|
|
||||||
self.host = state_machine
|
|
||||||
self.gitea = gitea
|
|
||||||
self.opencode = opencode
|
|
||||||
self.dispatcher = dispatcher
|
|
||||||
self.poll_seconds = poll_seconds
|
|
||||||
self.max_concurrent_jobs = max_concurrent_jobs
|
|
||||||
self.workspaces_dir = workspaces_dir
|
|
||||||
self.bot_username = bot_username
|
|
||||||
|
|
||||||
async def run(self, stop: asyncio.Event) -> None:
|
|
||||||
await self._recover()
|
|
||||||
await asyncio.gather(
|
|
||||||
self._loop("control", stop),
|
|
||||||
*(self._loop("jobs", stop) for _ in range(self.max_concurrent_jobs)),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _loop(self, queue: str, stop: asyncio.Event) -> None:
|
|
||||||
while not stop.is_set():
|
|
||||||
if queue == "jobs" and not await self.opencode.ready():
|
|
||||||
await self._wait(stop)
|
|
||||||
continue
|
|
||||||
task = await self.storage.claim_task(queue)
|
|
||||||
if task is None:
|
|
||||||
await self._wait(stop)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await self._handle(task)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
log.exception(
|
|
||||||
"listener failed",
|
|
||||||
extra={"task_id": task.id, "listener": task.listener, "queue": queue},
|
|
||||||
)
|
|
||||||
await self.storage.retry_task(task.id, task.attempts, _safe_error(exc))
|
|
||||||
else:
|
|
||||||
await self.storage.complete_task(task.id)
|
|
||||||
|
|
||||||
async def _handle(self, task: ListenerTask) -> None:
|
|
||||||
state = await self.host.get(task.job_id)
|
|
||||||
if state is None:
|
|
||||||
return
|
|
||||||
if task.listener == "authorize":
|
|
||||||
await self._authorize(task, state)
|
|
||||||
elif task.listener == "execute":
|
|
||||||
await self._execute(task, state)
|
|
||||||
elif task.listener == "reconcile_comment":
|
|
||||||
await self._reconcile(task, state)
|
|
||||||
elif task.listener == "fail_workflow":
|
|
||||||
await self.storage.fail_job_workflow(state.id)
|
|
||||||
elif task.listener == "abort_sessions":
|
|
||||||
await self._abort_job_sessions(state)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Unknown listener {task.listener}")
|
|
||||||
|
|
||||||
async def _authorize(self, task: ListenerTask, state: JobState) -> None:
|
|
||||||
if state.status is not JobStatus.RECEIVED:
|
|
||||||
return
|
|
||||||
permitted = await self.gitea.has_write_permission(
|
|
||||||
state.repo_owner, state.repo_name, state.requester
|
|
||||||
)
|
|
||||||
event = (
|
|
||||||
PermissionGranted(job_id=state.id)
|
|
||||||
if permitted
|
|
||||||
else PermissionDenied(job_id=state.id)
|
|
||||||
)
|
|
||||||
outcome = "permission-granted" if permitted else "permission-denied"
|
|
||||||
await self.host.evolve(f"task:{task.id}:{outcome}", event)
|
|
||||||
|
|
||||||
async def _execute(self, task: ListenerTask, state: JobState) -> None:
|
|
||||||
if state.status is JobStatus.RUNNING:
|
|
||||||
await self.host.evolve(
|
|
||||||
f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if state.status is not JobStatus.QUEUED:
|
|
||||||
return
|
|
||||||
result = await self.host.evolve(
|
|
||||||
f"task:{task.id}:started", JobStarted(job_id=state.id)
|
|
||||||
)
|
|
||||||
running = result.state
|
|
||||||
reporter = JobReporter(self.host, state.id, task.id)
|
|
||||||
token = bind_reporter(reporter)
|
|
||||||
try:
|
|
||||||
await self.dispatcher.dispatch(running)
|
|
||||||
except JobRejected as exc:
|
|
||||||
await self.host.evolve(
|
|
||||||
f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc))
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
latest = await self.host.get(state.id)
|
|
||||||
stage = latest.stage if latest else running.stage
|
|
||||||
await self.host.evolve(
|
|
||||||
f"task:{task.id}:failed",
|
|
||||||
JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.host.evolve(
|
|
||||||
f"task:{task.id}:completed",
|
|
||||||
JobCompleted(
|
|
||||||
job_id=state.id,
|
|
||||||
comment_body=reporter.final_body or "Agent job completed.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
reset_reporter(token)
|
|
||||||
|
|
||||||
async def _reconcile(self, task: ListenerTask, state: JobState) -> None:
|
|
||||||
latest = await self.host.get(state.id)
|
|
||||||
if latest is None:
|
|
||||||
return
|
|
||||||
body = render_job_comment(latest)
|
|
||||||
comment_id = latest.accepted_comment_id
|
|
||||||
if comment_id is not None and await self.gitea.update_comment(
|
|
||||||
latest.repo_owner, latest.repo_name, comment_id, body
|
|
||||||
):
|
|
||||||
return
|
|
||||||
marker = f"<!-- agentci:job id={latest.id} -->"
|
|
||||||
matches = sorted(
|
|
||||||
comment.id
|
|
||||||
for comment in await self.gitea.issue_comments(
|
|
||||||
latest.repo_owner, latest.repo_name, latest.issue_number
|
|
||||||
)
|
|
||||||
if comment.body.startswith(marker)
|
|
||||||
and comment.author.casefold() == self.bot_username.casefold()
|
|
||||||
)
|
|
||||||
if matches:
|
|
||||||
comment_id = matches[0]
|
|
||||||
else:
|
|
||||||
comment_id = await self.gitea.create_comment(
|
|
||||||
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
|
||||||
)
|
|
||||||
await self.host.evolve(
|
|
||||||
f"task:{task.id}:comment:{comment_id}",
|
|
||||||
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
|
||||||
)
|
|
||||||
await self.gitea.update_comment(
|
|
||||||
latest.repo_owner, latest.repo_name, comment_id, body
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _recover(self) -> None:
|
|
||||||
await self.storage.recover_tasks()
|
|
||||||
for state in await self.storage.running_job_states():
|
|
||||||
await self.host.evolve(
|
|
||||||
f"recovery:{state.id}:service-restarted",
|
|
||||||
ServiceRestarted(job_id=state.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _abort_job_sessions(self, state: JobState) -> None:
|
|
||||||
sessions: set[tuple[str, Path]] = set()
|
|
||||||
workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None
|
|
||||||
if workflow:
|
|
||||||
sessions.update(
|
|
||||||
(session, workflow.workspace_path)
|
|
||||||
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
|
|
||||||
if session
|
|
||||||
)
|
|
||||||
elif state.runtime_session_id:
|
|
||||||
sessions.add(
|
|
||||||
(state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo")
|
|
||||||
)
|
|
||||||
for session, workspace in sessions:
|
|
||||||
await self.opencode.abort(session, workspace)
|
|
||||||
|
|
||||||
async def _wait(self, stop: asyncio.Event) -> None:
|
|
||||||
with suppress(TimeoutError):
|
|
||||||
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_error(error: Exception) -> str:
|
|
||||||
message = " ".join(str(error).split())
|
|
||||||
return f"{type(error).__name__}: {message}"[:1000]
|
|
||||||
@@ -1 +1 @@
|
|||||||
"""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,40 +1,50 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.engine.repository import Repository
|
||||||
from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo
|
from agentci.integrations.gitea.client import Gitea
|
||||||
from agentci.adapters.storage import Storage
|
from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo
|
||||||
|
|
||||||
|
|
||||||
class ContextBuilder:
|
async def build_issue_context(
|
||||||
def __init__(self, gitea: GiteaClient, storage: Storage) -> None:
|
gitea: Gitea,
|
||||||
self.gitea = gitea
|
repository: Repository,
|
||||||
self.storage = storage
|
owner: str,
|
||||||
|
repo: str,
|
||||||
async def issue_context(self, owner: str, repo: str, number: int) -> str:
|
number: int,
|
||||||
issue = await self.gitea.issue(owner, repo, number)
|
) -> str:
|
||||||
comments = await self.gitea.issue_comments(owner, repo, number)
|
issue, comments, operational = await asyncio.gather(
|
||||||
operational = await self.storage.operational_comment_ids(owner, repo, number)
|
gitea.issue(owner, repo, number),
|
||||||
|
gitea.issue_comments(owner, repo, number),
|
||||||
|
repository.operational_comment_ids(owner, repo, number),
|
||||||
|
)
|
||||||
discussion = "\n\n".join(
|
discussion = "\n\n".join(
|
||||||
_format_comment(comment) for comment in comments if comment.id not in operational
|
_format_comment(comment) for comment in comments if comment.id not in operational
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f"Repository: {owner}/{repo}\n"
|
f"Repository: {owner}/{repo}\n"
|
||||||
f"Issue: #{number} — {issue.title}\n"
|
f"Issue: #{number} \u2014 {issue.title}\n"
|
||||||
f"State: {issue.state}\n\n"
|
f"State: {issue.state}\n\n"
|
||||||
f"## Issue body\n{issue.body or '(empty)'}\n\n"
|
f"## Issue body\n{issue.body or '(empty)'}\n\n"
|
||||||
f"## Discussion\n{discussion or '(none)'}"
|
f"## Discussion\n{discussion or '(none)'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def pull_request_context(
|
|
||||||
self, owner: str, repo: str, number: int
|
async def build_pull_request_context(
|
||||||
|
gitea: Gitea,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
) -> tuple[PullRequestInfo, str]:
|
) -> tuple[PullRequestInfo, str]:
|
||||||
pull = await self.gitea.pull_request(owner, repo, number)
|
pull, timeline, reviews, commits = await asyncio.gather(
|
||||||
timeline = await self.gitea.issue_comments(owner, repo, number)
|
gitea.pull_request(owner, repo, number),
|
||||||
reviews = await self.gitea.pull_reviews(owner, repo, number)
|
gitea.issue_comments(owner, repo, number),
|
||||||
commits = await self.gitea.pull_commits(owner, repo, number)
|
gitea.pull_reviews(owner, repo, number),
|
||||||
review_text = await self._format_reviews(owner, repo, number, reviews)
|
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)
|
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
|
||||||
commit_text = "\n".join(
|
commit_text = "\n".join(
|
||||||
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
|
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
|
||||||
@@ -42,7 +52,7 @@ class ContextBuilder:
|
|||||||
)
|
)
|
||||||
context = (
|
context = (
|
||||||
f"Repository: {owner}/{repo}\n"
|
f"Repository: {owner}/{repo}\n"
|
||||||
f"Pull request: #{number} — {pull.title}\n"
|
f"Pull request: #{number} \u2014 {pull.title}\n"
|
||||||
f"State: {pull.state}; merged: {pull.merged}\n"
|
f"State: {pull.state}; merged: {pull.merged}\n"
|
||||||
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
|
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
|
||||||
f"{pull.head_branch} @ {pull.head_sha}\n\n"
|
f"{pull.head_branch} @ {pull.head_sha}\n\n"
|
||||||
@@ -53,21 +63,35 @@ class ContextBuilder:
|
|||||||
)
|
)
|
||||||
return pull, context
|
return pull, context
|
||||||
|
|
||||||
|
|
||||||
async def _format_reviews(
|
async def _format_reviews(
|
||||||
self,
|
gitea: Gitea,
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
number: int,
|
number: int,
|
||||||
reviews: list[dict[str, Any]],
|
reviews: list[dict[str, Any]],
|
||||||
) -> str:
|
) -> str:
|
||||||
|
details = [
|
||||||
|
(
|
||||||
|
int(review["id"]),
|
||||||
|
review.get("user", {}).get("login", "unknown"),
|
||||||
|
review.get("state", "unknown"),
|
||||||
|
review.get("body") or "(empty)",
|
||||||
|
)
|
||||||
|
for review in reviews
|
||||||
|
]
|
||||||
|
comment_groups = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
gitea.review_comments(owner, repo, number, review_id)
|
||||||
|
for review_id, _, _, _ in details
|
||||||
|
)
|
||||||
|
)
|
||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
for review in reviews:
|
for (review_id, author, state, body), comments in zip(
|
||||||
review_id = int(review["id"])
|
details, comment_groups, strict=True
|
||||||
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}"]
|
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
|
||||||
for comment in await self.gitea.review_comments(owner, repo, number, review_id):
|
for comment in comments:
|
||||||
path = comment.get("path") or "unknown file"
|
path = comment.get("path") or "unknown file"
|
||||||
line = comment.get("new_position") or comment.get("old_position") or "?"
|
line = comment.get("new_position") or comment.get("old_position") or "?"
|
||||||
text = comment.get("body") or ""
|
text = comment.get("body") or ""
|
||||||
@@ -78,4 +102,3 @@ class ContextBuilder:
|
|||||||
|
|
||||||
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
|
||||||
|
)
|
||||||
+93
-139
@@ -1,42 +1,37 @@
|
|||||||
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
|
|
||||||
|
|
||||||
async def plan(self, job: Job) -> None:
|
|
||||||
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
|
|
||||||
workflow_id = str(uuid4())
|
workflow_id = str(uuid4())
|
||||||
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
|
workspace = services.settings.workspaces_dir / workflow_id / "repo"
|
||||||
await reporter().progress("cloning")
|
|
||||||
base_sha = await self.deps.git.clone(
|
await run.stage("cloning")
|
||||||
|
base_sha = await services.git.clone(
|
||||||
job.repo_owner,
|
job.repo_owner,
|
||||||
job.repo_name,
|
job.repo_name,
|
||||||
repository.default_branch,
|
default_branch,
|
||||||
workspace,
|
workspace,
|
||||||
)
|
)
|
||||||
workflow = Workflow(
|
workflow = Workflow(
|
||||||
@@ -48,35 +43,52 @@ class PlanWorkflow:
|
|||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
base_sha=base_sha,
|
base_sha=base_sha,
|
||||||
)
|
)
|
||||||
await reporter().create_workflow(workflow, "planning")
|
await run.create_workflow(workflow, "planning")
|
||||||
context = await self.deps.context.issue_context(
|
|
||||||
job.repo_owner, job.repo_name, job.issue_number
|
context = await build_issue_context(
|
||||||
|
services.gitea,
|
||||||
|
services.repository,
|
||||||
|
job.repo_owner,
|
||||||
|
job.repo_name,
|
||||||
|
job.issue_number,
|
||||||
)
|
)
|
||||||
prompt = self.deps.prompts.render(
|
prompt = services.prompts.render(
|
||||||
"plan_initial",
|
"plan_initial",
|
||||||
context=context,
|
context=context,
|
||||||
request=job.message or "(no additional request)",
|
request=job.message or "(no additional request)",
|
||||||
)
|
)
|
||||||
session_id = await self.deps.opencode.create_session(workspace, "plan")
|
session_id = await services.opencode.create_session(workspace, "plan")
|
||||||
workflow.primary_session_id = session_id
|
workflow = replace(workflow, primary_session_id=session_id)
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await services.repository.save_workflow(workflow)
|
||||||
await reporter().link_runtime_session(session_id)
|
await run.link_session(session_id)
|
||||||
artifact = await self.deps.opencode.resume(
|
|
||||||
|
artifact = await services.opencode.resume(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=services.settings.plan_model,
|
||||||
variant=self.deps.settings.plan_variant,
|
variant=services.settings.plan_variant,
|
||||||
schema_name="plan.json",
|
schema_name="plan.json",
|
||||||
result_type=PlanArtifact,
|
result_type=PlanArtifact,
|
||||||
)
|
)
|
||||||
workflow.artifact = artifact.plan_markdown
|
workflow = replace(workflow, artifact=artifact.plan_markdown)
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await services.repository.save_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:
|
workflow, artifact, report = await review_plan_loop(
|
||||||
workflow = await self._latest_plan(job)
|
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":
|
if workflow.runtime != "opencode":
|
||||||
raise JobRejected(
|
raise JobRejected(
|
||||||
"The latest plan predates OpenCode and cannot be resumed; "
|
"The latest plan predates OpenCode and cannot be resumed; "
|
||||||
@@ -84,148 +96,90 @@ class PlanWorkflow:
|
|||||||
)
|
)
|
||||||
if not workflow.primary_session_id or not workflow.artifact:
|
if not workflow.primary_session_id or not workflow.artifact:
|
||||||
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
|
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(
|
await run.link_workflow(workflow.id, "discussing")
|
||||||
"discuss", artifact=workflow.artifact, message=job.message
|
prompt = services.prompts.render(
|
||||||
|
"discuss", artifact=workflow.artifact, message=job.message or ""
|
||||||
)
|
)
|
||||||
reply = await self.deps.opencode.resume(
|
reply = await services.opencode.resume(
|
||||||
session_id=workflow.primary_session_id,
|
session_id=workflow.primary_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=services.settings.plan_model,
|
||||||
variant=self.deps.settings.plan_variant,
|
variant=services.settings.plan_variant,
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="discussion.json",
|
schema_name="discussion.json",
|
||||||
result_type=DiscussionReply,
|
result_type=DiscussionReply,
|
||||||
)
|
)
|
||||||
await finish_job(agent_comment("discussion", workflow.id, reply.markdown))
|
return agent_comment("discussion", workflow.id, reply.markdown)
|
||||||
|
|
||||||
async def iterate(self, job: Job) -> None:
|
|
||||||
await self._reject_if_active_or_merged_pr(job)
|
async def iterate_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
|
||||||
workflow = await self._latest_plan(job)
|
await _reject_if_active_or_merged_pr(job, services)
|
||||||
|
workflow = await _latest_plan(job, services)
|
||||||
if workflow.runtime != "opencode":
|
if workflow.runtime != "opencode":
|
||||||
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
|
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
|
||||||
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
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.")
|
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
|
||||||
if not workflow.artifact:
|
if not workflow.artifact:
|
||||||
raise JobRejected("The latest plan has no saved 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(
|
await run.link_workflow(workflow.id, "iterating plan")
|
||||||
job.repo_owner, job.repo_name, job.issue_number
|
context = await build_issue_context(
|
||||||
|
services.gitea,
|
||||||
|
services.repository,
|
||||||
|
job.repo_owner,
|
||||||
|
job.repo_name,
|
||||||
|
job.issue_number,
|
||||||
)
|
)
|
||||||
prompt = self.deps.prompts.render(
|
prompt = services.prompts.render(
|
||||||
"plan_iterate",
|
"plan_iterate",
|
||||||
context=context,
|
context=context,
|
||||||
artifact=workflow.artifact,
|
artifact=workflow.artifact,
|
||||||
review=report_for_prompt(workflow.review_json),
|
review=report_for_prompt(workflow.review_json),
|
||||||
message=job.message or "(refine using the latest discussion and prior review)",
|
message=job.message or "(refine using the latest discussion and prior review)",
|
||||||
)
|
)
|
||||||
artifact = await self.deps.opencode.resume(
|
artifact = await services.opencode.resume(
|
||||||
session_id=workflow.primary_session_id,
|
session_id=workflow.primary_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=services.settings.plan_model,
|
||||||
variant=self.deps.settings.plan_variant,
|
variant=services.settings.plan_variant,
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="plan.json",
|
schema_name="plan.json",
|
||||||
result_type=PlanArtifact,
|
result_type=PlanArtifact,
|
||||||
)
|
)
|
||||||
report = await self._review(workflow, context, artifact)
|
workflow, report = await review_plan_once(workflow, context, artifact, services)
|
||||||
await self._finish(job, workflow, artifact, report)
|
workflow = replace(
|
||||||
|
workflow,
|
||||||
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,
|
artifact=artifact.plan_markdown,
|
||||||
review=report_for_prompt(workflow.review_json),
|
review_json=report.model_dump_json(),
|
||||||
|
status=WorkflowStatus.COMPLETED,
|
||||||
)
|
)
|
||||||
artifact = await self.deps.opencode.resume(
|
await services.repository.save_workflow(workflow)
|
||||||
session_id=required_session(workflow.primary_session_id),
|
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
|
||||||
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(
|
async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow:
|
||||||
self,
|
workflow = await services.repository.latest_workflow(
|
||||||
job: Job,
|
job.repo_owner,
|
||||||
workflow: Workflow,
|
job.repo_name,
|
||||||
artifact: PlanArtifact,
|
job.issue_number,
|
||||||
report: ReviewReport,
|
WorkflowKind.PLAN,
|
||||||
) -> 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:
|
if workflow is None:
|
||||||
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
|
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
|
||||||
return workflow
|
return workflow
|
||||||
|
|
||||||
async def _reject_if_active_or_merged_pr(self, job: Job) -> None:
|
|
||||||
workflows = await self.deps.storage.implementation_workflows(
|
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
|
job.repo_owner, job.repo_name, job.issue_number
|
||||||
)
|
)
|
||||||
for workflow in workflows:
|
for workflow in workflows:
|
||||||
if workflow.pr_number is None:
|
if workflow.pr_number is None:
|
||||||
continue
|
continue
|
||||||
pull = await self.deps.gitea.pull_request(
|
pull = await services.gitea.pull_request(
|
||||||
job.repo_owner, job.repo_name, workflow.pr_number
|
job.repo_owner, job.repo_name, workflow.pr_number
|
||||||
)
|
)
|
||||||
if pull.is_open or pull.merged:
|
if pull.is_open or pull.merged:
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
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)
|
|
||||||
self.changes = ChangeSet(dependencies)
|
|
||||||
|
|
||||||
async def iterate(self, job: Job) -> None:
|
|
||||||
pull_number = _pull_number(job)
|
pull_number = _pull_number(job)
|
||||||
workflow = await self.deps.storage.workflow_for_pr(
|
workflow = await services.repository.workflow_for_pr(
|
||||||
job.repo_owner, job.repo_name, pull_number
|
job.repo_owner, job.repo_name, pull_number
|
||||||
)
|
)
|
||||||
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED:
|
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED:
|
||||||
@@ -34,109 +36,125 @@ class PullRequestWorkflow:
|
|||||||
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
|
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
|
||||||
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
||||||
raise JobRejected("The implementation sessions cannot be resumed.")
|
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
|
pull, context = await build_pull_request_context(
|
||||||
|
services.gitea, job.repo_owner, job.repo_name, pull_number
|
||||||
)
|
)
|
||||||
if not pull.is_open:
|
if not pull.is_open:
|
||||||
raise JobRejected("Implementation iteration requires an open pull request.")
|
raise JobRejected("Implementation iteration requires an open pull request.")
|
||||||
if workflow.branch != pull.head_branch:
|
if workflow.branch != pull.head_branch:
|
||||||
raise JobRejected("The pull request head branch no longer matches its workflow.")
|
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 run.link_workflow(workflow.id, "synchronizing branch")
|
||||||
await reporter().progress("installing development environment")
|
await services.git.sync_branch(workflow.workspace_path, pull.head_branch)
|
||||||
await self.deps.development.prepare(workflow.workspace_path)
|
await run.stage("installing development environment")
|
||||||
await reporter().progress("implementing iteration")
|
await services.development.prepare(workflow.workspace_path)
|
||||||
prompt = self.deps.prompts.render(
|
await run.stage("implementing iteration")
|
||||||
|
prompt = services.prompts.render(
|
||||||
"implementation_iterate",
|
"implementation_iterate",
|
||||||
context=context,
|
context=context,
|
||||||
review=report_for_prompt(workflow.review_json),
|
review=report_for_prompt(workflow.review_json),
|
||||||
message=job.message or "(perform one additional reviewed refinement)",
|
message=job.message or "(perform one additional reviewed refinement)",
|
||||||
development_environment=self.deps.development.description,
|
development_environment=services.development.description,
|
||||||
)
|
)
|
||||||
result = await self.deps.opencode.resume(
|
result = await services.opencode.resume(
|
||||||
session_id=workflow.primary_session_id,
|
session_id=workflow.primary_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=services.settings.implement_model,
|
||||||
variant=self.deps.settings.implement_variant,
|
variant=services.settings.implement_variant,
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="agent_result.json",
|
schema_name="agent_result.json",
|
||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
)
|
)
|
||||||
issue_context = await self.deps.context.issue_context(
|
|
||||||
job.repo_owner, job.repo_name, workflow.issue_number
|
issue_context = await build_issue_context(
|
||||||
|
services.gitea,
|
||||||
|
services.repository,
|
||||||
|
job.repo_owner,
|
||||||
|
job.repo_name,
|
||||||
|
workflow.issue_number,
|
||||||
)
|
)
|
||||||
plan = await self.deps.storage.latest_workflow(
|
plan = await services.repository.latest_workflow(
|
||||||
job.repo_owner, job.repo_name, workflow.issue_number, WorkflowKind.PLAN
|
job.repo_owner,
|
||||||
|
job.repo_name,
|
||||||
|
workflow.issue_number,
|
||||||
|
WorkflowKind.PLAN,
|
||||||
)
|
)
|
||||||
report = await self.review.once(
|
workflow, report = await review_implementation_once(
|
||||||
workflow,
|
workflow,
|
||||||
issue_context=issue_context,
|
issue_context=issue_context,
|
||||||
plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
|
plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
|
||||||
pull_context=context,
|
pull_context=context,
|
||||||
|
services=services,
|
||||||
)
|
)
|
||||||
sha = await self.changes.commit_and_push(
|
sha = await commit_and_push(
|
||||||
job,
|
run,
|
||||||
|
services,
|
||||||
workflow.workspace_path,
|
workflow.workspace_path,
|
||||||
pull.head_branch,
|
pull.head_branch,
|
||||||
result,
|
result,
|
||||||
set_upstream=False,
|
set_upstream=False,
|
||||||
commit_prefix="agent iterate",
|
commit_prefix="agent iterate",
|
||||||
)
|
)
|
||||||
workflow.artifact = result.model_dump_json()
|
workflow = replace(
|
||||||
workflow.review_json = report_json(report)
|
workflow,
|
||||||
await self.deps.storage.update_workflow(workflow)
|
artifact=result.model_dump_json(),
|
||||||
body = agent_comment(
|
review_json=report.model_dump_json(),
|
||||||
"iteration", workflow.id, result_comment(result, sha=sha)
|
)
|
||||||
|
await services.repository.save_workflow(workflow)
|
||||||
|
return final_comment(
|
||||||
|
"iteration", workflow.id, result_comment(result, sha=sha), report
|
||||||
)
|
)
|
||||||
remaining = review_markdown(report)
|
|
||||||
if remaining:
|
|
||||||
body = f"{body}\n\n{remaining}"
|
|
||||||
await finish_job(body)
|
|
||||||
|
|
||||||
async def fix(self, job: Job) -> None:
|
|
||||||
|
async def fix_pull_request(
|
||||||
|
job: Job, run: JobRun, services: WorkflowServices
|
||||||
|
) -> str:
|
||||||
pull_number = _pull_number(job)
|
pull_number = _pull_number(job)
|
||||||
pull, context = await self.deps.context.pull_request_context(
|
pull, context = await build_pull_request_context(
|
||||||
job.repo_owner, job.repo_name, pull_number
|
services.gitea, job.repo_owner, job.repo_name, pull_number
|
||||||
)
|
)
|
||||||
if not pull.is_open:
|
if not pull.is_open:
|
||||||
raise JobRejected("Fixes require an open pull request.")
|
raise JobRejected("Fixes require an open pull request.")
|
||||||
workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo"
|
|
||||||
await reporter().progress("cloning pull request")
|
workspace = services.settings.workspaces_dir / f"fix-{job.id}" / "repo"
|
||||||
await self.deps.git.clone(
|
await run.stage("cloning pull request")
|
||||||
|
await services.git.clone(
|
||||||
pull.head_owner,
|
pull.head_owner,
|
||||||
pull.head_repo,
|
pull.head_repo,
|
||||||
pull.head_branch,
|
pull.head_branch,
|
||||||
workspace,
|
workspace,
|
||||||
)
|
)
|
||||||
await reporter().progress("installing development environment")
|
await run.stage("installing development environment")
|
||||||
await self.deps.development.prepare(workspace)
|
await services.development.prepare(workspace)
|
||||||
prompt = self.deps.prompts.render(
|
prompt = services.prompts.render(
|
||||||
"fix",
|
"fix",
|
||||||
context=context,
|
context=context,
|
||||||
message=job.message or "(address the pull request feedback)",
|
message=job.message or "(address the pull request feedback)",
|
||||||
development_environment=self.deps.development.description,
|
development_environment=services.development.description,
|
||||||
)
|
)
|
||||||
await reporter().progress("fixing")
|
await run.stage("fixing")
|
||||||
session_id = await self.deps.opencode.create_session(workspace, "fix")
|
session_id = await services.opencode.create_session(workspace, "fix")
|
||||||
await reporter().link_runtime_session(session_id)
|
await run.link_session(session_id)
|
||||||
result = await self.deps.opencode.resume(
|
result = await services.opencode.resume(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=services.settings.implement_model,
|
||||||
variant=self.deps.settings.implement_variant,
|
variant=services.settings.implement_variant,
|
||||||
schema_name="agent_result.json",
|
schema_name="agent_result.json",
|
||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
)
|
)
|
||||||
sha = await self.changes.commit_and_push(
|
sha = await commit_and_push(
|
||||||
job,
|
run,
|
||||||
|
services,
|
||||||
workspace,
|
workspace,
|
||||||
pull.head_branch,
|
pull.head_branch,
|
||||||
result,
|
result,
|
||||||
set_upstream=False,
|
set_upstream=False,
|
||||||
commit_prefix="agent fix",
|
commit_prefix="agent fix",
|
||||||
)
|
)
|
||||||
await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha)))
|
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.settings import Settings
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.development import DevelopmentEnvironment
|
||||||
|
from agentci.integrations.git import Git
|
||||||
|
from agentci.integrations.gitea.client import Gitea
|
||||||
|
from agentci.integrations.opencode.client import OpenCode
|
||||||
|
from agentci.prompts.library import PromptLibrary
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkflowServices:
|
||||||
|
settings: Settings
|
||||||
|
repository: Repository
|
||||||
|
gitea: Gitea
|
||||||
|
git: Git
|
||||||
|
opencode: OpenCode
|
||||||
|
prompts: PromptLibrary
|
||||||
|
development: DevelopmentEnvironment
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.engine import _sqlite
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def engine_repository(tmp_path: Path) -> Repository:
|
||||||
|
repository = Repository(tmp_path / "state.sqlite3")
|
||||||
|
await repository.initialize()
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SQLiteClock:
|
||||||
|
now: str = "2026-02-01T00:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sqlite_clock(monkeypatch: pytest.MonkeyPatch) -> SQLiteClock:
|
||||||
|
clock = SQLiteClock()
|
||||||
|
monkeypatch.setattr(_sqlite, "now", lambda: clock.now)
|
||||||
|
return clock
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
import agentci.api.app as app_module
|
||||||
|
import agentci.api.lifespan as lifespan_module
|
||||||
|
|
||||||
|
|
||||||
|
class BlockingWorker:
|
||||||
|
def __init__(self, events: list[str]) -> None:
|
||||||
|
self.events = events
|
||||||
|
self.started = asyncio.Event()
|
||||||
|
|
||||||
|
async def run(self, stop: asyncio.Event) -> None:
|
||||||
|
self.events.append("worker-started")
|
||||||
|
self.started.set()
|
||||||
|
try:
|
||||||
|
await stop.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.events.append(f"worker-cancelled:{stop.is_set()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class FailingWorker:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.started = asyncio.Event()
|
||||||
|
|
||||||
|
async def run(self, _stop: asyncio.Event) -> None:
|
||||||
|
self.started.set()
|
||||||
|
raise RuntimeError("worker failed")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRuntime:
|
||||||
|
def __init__(self, worker: object, events: list[str]) -> None:
|
||||||
|
self.worker = worker
|
||||||
|
self.events = events
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.events.append("runtime-closed")
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
worker = BlockingWorker(events)
|
||||||
|
runtime = FakeRuntime(worker, events)
|
||||||
|
selected_settings = SimpleNamespace(name="selected")
|
||||||
|
built_with: list[object] = []
|
||||||
|
configured: list[bool] = []
|
||||||
|
|
||||||
|
async def build(settings: object) -> FakeRuntime:
|
||||||
|
built_with.append(settings)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
||||||
|
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
||||||
|
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
await worker.started.wait()
|
||||||
|
assert application.state.runtime is runtime
|
||||||
|
assert not runtime.closed
|
||||||
|
|
||||||
|
assert built_with == [selected_settings]
|
||||||
|
assert configured == [True]
|
||||||
|
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifespan_closes_runtime_when_worker_task_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
worker = FailingWorker()
|
||||||
|
runtime = FakeRuntime(worker, events)
|
||||||
|
|
||||||
|
async def build(_settings: object) -> FakeRuntime:
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
||||||
|
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None)
|
||||||
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="worker failed"):
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
await worker.started.wait()
|
||||||
|
|
||||||
|
assert runtime.closed
|
||||||
|
assert events == ["runtime-closed"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
configured: list[bool] = []
|
||||||
|
|
||||||
|
async def fail_build(_settings: object) -> None:
|
||||||
|
raise RuntimeError("database unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifespan_module, "build_runtime", fail_build)
|
||||||
|
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
||||||
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
pytest.fail("startup failure must prevent serving requests")
|
||||||
|
|
||||||
|
assert configured == [True]
|
||||||
|
assert not hasattr(application.state, "runtime")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_global_exception_handler_returns_safe_json() -> None:
|
||||||
|
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
@application.get("/explode")
|
||||||
|
async def explode() -> None:
|
||||||
|
raise RuntimeError("sensitive provider detail")
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
response = await client.get("/explode")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
|
||||||
+283
-89
@@ -1,25 +1,31 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from agentci.domain.models import (
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from agentci.engine.model import Workflow, WorkflowKind
|
||||||
|
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_plan_loop
|
||||||
|
from tests.workflow_support import WorkflowHarness, make_workflow_harness
|
||||||
|
|
||||||
|
|
||||||
def serious_report() -> ReviewReport:
|
def serious_report(
|
||||||
|
summary: str = "Needs work",
|
||||||
|
*,
|
||||||
|
severity: ReviewSeverity = ReviewSeverity.MAJOR,
|
||||||
|
) -> ReviewReport:
|
||||||
return ReviewReport(
|
return ReviewReport(
|
||||||
summary="Needs work",
|
summary=summary,
|
||||||
findings=[
|
findings=[
|
||||||
ReviewFinding(
|
ReviewFinding(
|
||||||
severity=ReviewSeverity.MAJOR,
|
severity=severity,
|
||||||
title="Missing check",
|
title="Missing check",
|
||||||
detail="A check is absent.",
|
detail="A check is absent.",
|
||||||
recommendation="Add it.",
|
recommendation="Add it.",
|
||||||
@@ -28,101 +34,289 @@ 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,
|
|
||||||
storage=FakeStorage(),
|
|
||||||
prompts=FakePrompts(),
|
|
||||||
development=SimpleNamespace(description="python"),
|
|
||||||
)
|
)
|
||||||
workflow = Workflow(
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self, trace: list[tuple[object, ...]] | None = None) -> None:
|
||||||
|
self.saved_workflows: list[Workflow] = []
|
||||||
|
self.trace = trace
|
||||||
|
|
||||||
|
async def save_workflow(self, workflow: Workflow) -> None:
|
||||||
|
self.saved_workflows.append(workflow)
|
||||||
|
if self.trace is not None:
|
||||||
|
self.trace.append(("save_workflow", workflow.reviewer_session_id))
|
||||||
|
|
||||||
|
|
||||||
|
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 review_harness(
|
||||||
clean = ReviewReport(summary="Ready", findings=[])
|
responses: Iterable[BaseModel],
|
||||||
loop, opencode, workflow, job = objects(4, [serious_report(), clean])
|
repository: FakeRepository,
|
||||||
_, report = await loop.run(
|
*,
|
||||||
job,
|
trace: list[tuple[object, ...]] | None = None,
|
||||||
workflow,
|
plan_rounds: int = 4,
|
||||||
|
implementation_rounds: int = 3,
|
||||||
|
) -> WorkflowHarness:
|
||||||
|
return make_workflow_harness(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
repository=repository,
|
||||||
|
gitea=object(),
|
||||||
|
responses=responses,
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None:
|
||||||
|
revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"])
|
||||||
|
repository = FakeRepository()
|
||||||
|
harness = review_harness(
|
||||||
|
[serious_report(), revised, clean_report()],
|
||||||
|
repository,
|
||||||
|
implementation_rounds=4,
|
||||||
|
)
|
||||||
|
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=[]),
|
||||||
|
harness.run,
|
||||||
|
harness.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 harness.run.stages == [
|
||||||
|
"reviewing implementation 1/4",
|
||||||
|
"reviewing implementation 2/4",
|
||||||
|
]
|
||||||
|
assert harness.opencode.created_sessions == [(original.workspace_path, "implementation-review")]
|
||||||
|
assert [call["session_id"] for call in harness.opencode.resume_calls] == [
|
||||||
|
"implementation-review-session",
|
||||||
|
"primary-session",
|
||||||
|
"implementation-review-session",
|
||||||
|
]
|
||||||
|
assert [call["result_type"] for call in harness.opencode.resume_calls] == [
|
||||||
|
ReviewReport,
|
||||||
|
AgentResult,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
assert [name for name, _ in harness.prompts.calls] == [
|
||||||
|
"implementation_review",
|
||||||
|
"implementation_revision",
|
||||||
|
"implementation_review",
|
||||||
|
]
|
||||||
|
assert harness.opencode.responses == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None:
|
||||||
|
final_report = serious_report(
|
||||||
|
"Still failing after the last review",
|
||||||
|
severity=ReviewSeverity.BLOCKING,
|
||||||
|
)
|
||||||
|
repository = FakeRepository()
|
||||||
|
harness = review_harness(
|
||||||
|
[
|
||||||
|
serious_report("round 1"),
|
||||||
|
AgentResult(summary_markdown="revision 1", tests=[]),
|
||||||
|
serious_report("round 2"),
|
||||||
|
AgentResult(summary_markdown="revision 2", tests=[]),
|
||||||
|
final_report,
|
||||||
|
],
|
||||||
|
repository,
|
||||||
|
implementation_rounds=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated, result, report = await review_implementation_loop(
|
||||||
|
workflow(),
|
||||||
|
"issue context",
|
||||||
|
"canonical plan",
|
||||||
|
AgentResult(summary_markdown="initial", tests=[]),
|
||||||
|
harness.run,
|
||||||
|
harness.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 harness.opencode.resume_calls] == [
|
||||||
|
ReviewReport,
|
||||||
|
AgentResult,
|
||||||
|
ReviewReport,
|
||||||
|
AgentResult,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
assert harness.run.stages == [
|
||||||
|
"reviewing implementation 1/3",
|
||||||
|
"reviewing implementation 2/3",
|
||||||
|
"reviewing implementation 3/3",
|
||||||
|
]
|
||||||
|
assert harness.opencode.responses == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None:
|
||||||
|
revised = PlanArtifact(plan_markdown="Revised plan")
|
||||||
|
trace: list[tuple[object, ...]] = []
|
||||||
|
repository = FakeRepository(trace)
|
||||||
|
harness = review_harness(
|
||||||
|
[serious_report(), revised, clean_report()],
|
||||||
|
repository,
|
||||||
|
trace=trace,
|
||||||
|
plan_rounds=4,
|
||||||
|
)
|
||||||
|
original = workflow()
|
||||||
|
initial = PlanArtifact(plan_markdown="Initial plan")
|
||||||
|
|
||||||
|
updated, artifact, report = await review_plan_loop(
|
||||||
|
original,
|
||||||
|
"issue context",
|
||||||
|
initial,
|
||||||
|
harness.run,
|
||||||
|
harness.services,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert artifact is revised
|
||||||
|
assert report == clean_report()
|
||||||
|
assert original.reviewer_session_id is None
|
||||||
|
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 harness.run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"]
|
||||||
|
assert harness.opencode.created_sessions == [(original.workspace_path, "plan-review")]
|
||||||
|
assert harness.opencode.resume_calls[0] == {
|
||||||
|
"session_id": "plan-review-session",
|
||||||
|
"workspace": original.workspace_path,
|
||||||
|
"prompt": "rendered plan_review",
|
||||||
|
"model": "provider/plan",
|
||||||
|
"variant": "high",
|
||||||
|
"schema_name": "review.json",
|
||||||
|
"result_type": ReviewReport,
|
||||||
|
}
|
||||||
|
assert harness.prompts.calls[0] == (
|
||||||
|
"plan_review",
|
||||||
|
{"context": "issue context", "artifact": "Initial plan"},
|
||||||
|
)
|
||||||
|
assert trace[:3] == [
|
||||||
|
("create_session", "plan-review", "plan-review-session"),
|
||||||
|
("save_workflow", "plan-review-session"),
|
||||||
|
("resume", "plan-review-session", ReviewReport),
|
||||||
|
]
|
||||||
|
assert [call["session_id"] for call in harness.opencode.resume_calls] == [
|
||||||
|
"plan-review-session",
|
||||||
|
"primary-session",
|
||||||
|
"plan-review-session",
|
||||||
|
]
|
||||||
|
assert [name for name, _ in harness.prompts.calls] == [
|
||||||
|
"plan_review",
|
||||||
|
"plan_revision",
|
||||||
|
"plan_review",
|
||||||
|
]
|
||||||
|
assert harness.opencode.responses == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None:
|
||||||
|
final_report = serious_report("round 2")
|
||||||
|
repository = FakeRepository()
|
||||||
|
harness = review_harness(
|
||||||
|
[
|
||||||
|
serious_report("round 1"),
|
||||||
|
PlanArtifact(plan_markdown="Only revision"),
|
||||||
|
final_report,
|
||||||
|
],
|
||||||
|
repository,
|
||||||
|
plan_rounds=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated, artifact, report = await review_plan_loop(
|
||||||
|
workflow(),
|
||||||
|
"issue context",
|
||||||
|
PlanArtifact(plan_markdown="Initial plan"),
|
||||||
|
harness.run,
|
||||||
|
harness.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 harness.opencode.resume_calls] == [
|
||||||
|
ReviewReport,
|
||||||
|
PlanArtifact,
|
||||||
|
ReviewReport,
|
||||||
|
]
|
||||||
|
assert harness.run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"]
|
||||||
|
assert harness.opencode.responses == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_minor_findings_end_review_loop_without_revision() -> None:
|
||||||
|
repository = FakeRepository()
|
||||||
|
harness = review_harness(
|
||||||
|
[minor_report()],
|
||||||
|
repository,
|
||||||
|
implementation_rounds=5,
|
||||||
|
)
|
||||||
|
initial = AgentResult(summary_markdown="initial", tests=[])
|
||||||
|
|
||||||
|
updated, result, report = await review_implementation_loop(
|
||||||
|
workflow(),
|
||||||
|
"issue context",
|
||||||
|
"canonical plan",
|
||||||
|
initial,
|
||||||
|
harness.run,
|
||||||
|
harness.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 harness.opencode.resume_calls] == [ReviewReport]
|
||||||
async def test_does_not_make_unreviewed_final_revision() -> None:
|
assert harness.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
|
|
||||||
|
|||||||
+124
-32
@@ -1,59 +1,151 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from agentci.adapters.codegraph import CodeGraphClient
|
import pytest
|
||||||
|
|
||||||
|
from agentci.integrations.codegraph import CodeGraph, CodeGraphError
|
||||||
|
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
returncode = 0
|
def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
async def communicate(self) -> tuple[bytes, bytes]:
|
async def communicate(self) -> tuple[bytes, bytes]:
|
||||||
return b"", b""
|
return b"", self.stderr
|
||||||
|
|
||||||
|
|
||||||
async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
class CodeGraphProcessRecorder:
|
||||||
tmp_path: Path, monkeypatch
|
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 install_recorder(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
outcomes: Sequence[FakeProcess | OSError],
|
||||||
|
) -> CodeGraphProcessRecorder:
|
||||||
|
recorder = CodeGraphProcessRecorder(outcomes)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
|
||||||
|
recorder,
|
||||||
|
)
|
||||||
|
return recorder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("database_exists", "initial_exclude", "expected_command", "expected_exclude"),
|
||||||
|
[
|
||||||
|
pytest.param(False, None, "init", ".codegraph/\n", id="incomplete-index"),
|
||||||
|
pytest.param(
|
||||||
|
True,
|
||||||
|
"# local excludes\n.codegraph/\n",
|
||||||
|
"sync",
|
||||||
|
"# local excludes\n.codegraph/\n",
|
||||||
|
id="existing-index",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_prepare_selects_init_or_sync_and_updates_git_exclude(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
database_exists: bool,
|
||||||
|
initial_exclude: str | None,
|
||||||
|
expected_command: str,
|
||||||
|
expected_exclude: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
workspace = tmp_path / "repo"
|
workspace = tmp_path / "repo"
|
||||||
(workspace / ".git" / "info").mkdir(parents=True)
|
(workspace / ".git" / "info").mkdir(parents=True)
|
||||||
(workspace / ".codegraph").mkdir()
|
(workspace / ".codegraph").mkdir()
|
||||||
calls: list[tuple[object, ...]] = []
|
if database_exists:
|
||||||
|
(workspace / ".codegraph" / "codegraph.db").touch()
|
||||||
|
exclude = workspace / ".git" / "info" / "exclude"
|
||||||
|
if initial_exclude is not None:
|
||||||
|
exclude.write_text(initial_exclude)
|
||||||
|
recorder = install_recorder(monkeypatch, [FakeProcess()])
|
||||||
|
|
||||||
async def create_subprocess_exec(*args, **_kwargs):
|
await CodeGraph().prepare(workspace)
|
||||||
calls.append(args)
|
|
||||||
return FakeProcess()
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
assert recorder.commands == [("codegraph", expected_command, str(workspace))]
|
||||||
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
|
kwargs = recorder.calls[0][1]
|
||||||
create_subprocess_exec,
|
assert kwargs["cwd"] == workspace
|
||||||
|
assert kwargs["env"]["CODEGRAPH_TELEMETRY"] == "0"
|
||||||
|
assert kwargs["stdout"] is asyncio.subprocess.PIPE
|
||||||
|
assert kwargs["stderr"] is asyncio.subprocess.PIPE
|
||||||
|
assert exclude.read_text() == expected_exclude
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
install_recorder(monkeypatch, [FakeProcess()])
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FileNotFoundError(2, "No such file or directory", "codegraph")],
|
||||||
)
|
)
|
||||||
|
|
||||||
await CodeGraphClient().prepare(workspace)
|
with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised:
|
||||||
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
assert calls == [("codegraph", "init", str(workspace))]
|
assert isinstance(raised.value.__cause__, FileNotFoundError)
|
||||||
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
|
||||||
|
|
||||||
|
|
||||||
async def test_syncs_an_existing_index_without_duplicating_exclude(
|
async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
|
||||||
tmp_path: Path, monkeypatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
workspace = tmp_path / "repo"
|
workspace = tmp_path / "repo"
|
||||||
(workspace / ".git" / "info").mkdir(parents=True)
|
workspace.mkdir()
|
||||||
(workspace / ".codegraph").mkdir()
|
stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail"
|
||||||
(workspace / ".codegraph" / "codegraph.db").touch()
|
|
||||||
exclude = workspace / ".git" / "info" / "exclude"
|
|
||||||
exclude.write_text("# local excludes\n.codegraph/\n")
|
|
||||||
calls: list[tuple[object, ...]] = []
|
|
||||||
|
|
||||||
async def create_subprocess_exec(*args, **_kwargs):
|
install_recorder(
|
||||||
calls.append(args)
|
monkeypatch,
|
||||||
return FakeProcess()
|
[FakeProcess(returncode=7, stderr=stderr)],
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"agentci.adapters.codegraph.asyncio.create_subprocess_exec",
|
|
||||||
create_subprocess_exec,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await CodeGraphClient().prepare(workspace)
|
with pytest.raises(CodeGraphError, match="codegraph init failed") as raised:
|
||||||
|
await CodeGraph().prepare(workspace)
|
||||||
|
|
||||||
assert calls == [("codegraph", "sync", str(workspace))]
|
message = str(raised.value)
|
||||||
assert exclude.read_text() == "# local excludes\n.codegraph/\n"
|
assert "discarded-prefix" not in message
|
||||||
|
assert "useful-tail" in message
|
||||||
|
assert len(message.removeprefix("codegraph init failed: ")) == 1000
|
||||||
|
|||||||
+55
-33
@@ -1,38 +1,52 @@
|
|||||||
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:
|
||||||
assert parse_command("please run /agent plan") is None
|
assert parse_command("please run /agent plan") is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", list(CommandName))
|
@pytest.mark.parametrize(
|
||||||
@pytest.mark.parametrize("line_breaks", [1, 2, 5])
|
("name", "is_pull_request", "expected_kind"),
|
||||||
def test_all_commands_accept_messages_after_any_number_of_lines(
|
[
|
||||||
name: CommandName, line_breaks: int
|
(CommandName.PLAN, False, JobKind.PLAN),
|
||||||
) -> None:
|
(CommandName.DISCUSS, False, JobKind.DISCUSS),
|
||||||
command = parse_command(
|
(CommandName.IMPLEMENT, False, JobKind.IMPLEMENT),
|
||||||
f"/agent {name.value}{'\n' * line_breaks}"
|
(CommandName.ITERATE, False, JobKind.ITERATE_PLAN),
|
||||||
"focus on the API\nand add tests"
|
(CommandName.ITERATE, True, JobKind.ITERATE_IMPLEMENT),
|
||||||
|
(CommandName.FIX, True, JobKind.FIX),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
assert command is not None
|
def test_supported_commands_parse_and_resolve(
|
||||||
assert command.name is name
|
|
||||||
assert command.message == "focus on the API\nand add tests"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", list(CommandName))
|
|
||||||
def test_all_commands_accept_crlf_separated_multiline_messages(
|
|
||||||
name: CommandName,
|
name: CommandName,
|
||||||
|
is_pull_request: bool,
|
||||||
|
expected_kind: JobKind,
|
||||||
) -> None:
|
) -> None:
|
||||||
command = parse_command(
|
command = parse_command(f"/agent {name.value}\nfocus on the API\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, command.message) == (
|
||||||
assert command.message == "focus on the API\r\nand add tests"
|
name,
|
||||||
|
"focus on the API\nand add tests",
|
||||||
|
)
|
||||||
|
assert resolve_job_kind(command, is_pull_request=is_pull_request) is expected_kind
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("separator", "line_breaks"),
|
||||||
|
[("\n", 1), ("\n", 2), ("\n", 5), ("\r\n", 3)],
|
||||||
|
)
|
||||||
|
def test_multiline_messages_accept_line_separators(
|
||||||
|
separator: str,
|
||||||
|
line_breaks: int,
|
||||||
|
) -> None:
|
||||||
|
message = f"focus on the API{separator}and add tests"
|
||||||
|
command = parse_command(f"/agent {CommandName.PLAN.value}{separator * line_breaks}{message}")
|
||||||
|
|
||||||
|
assert command is not None
|
||||||
|
assert (command.name, command.message) == (CommandName.PLAN, message)
|
||||||
|
|
||||||
|
|
||||||
def test_discuss_requires_message() -> None:
|
def test_discuss_requires_message() -> None:
|
||||||
@@ -40,11 +54,25 @@ def test_discuss_requires_message() -> None:
|
|||||||
parse_command("/agent discuss")
|
parse_command("/agent discuss")
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_wrong_location() -> None:
|
@pytest.mark.parametrize(
|
||||||
command = parse_command("/agent fix")
|
("name", "is_pull_request", "error_match"),
|
||||||
|
[
|
||||||
|
(CommandName.FIX, False, "pull request"),
|
||||||
|
(CommandName.PLAN, True, "issue"),
|
||||||
|
(CommandName.DISCUSS, True, "issue"),
|
||||||
|
(CommandName.IMPLEMENT, True, "issue"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_wrong_location(
|
||||||
|
name: CommandName,
|
||||||
|
is_pull_request: bool,
|
||||||
|
error_match: str,
|
||||||
|
) -> None:
|
||||||
|
command = parse_command(f"/agent {name.value} details")
|
||||||
assert command is not None
|
assert command is not None
|
||||||
with pytest.raises(CommandError, match="pull request"):
|
|
||||||
resolve_job_kind(command, is_pull_request=False)
|
with pytest.raises(CommandError, match=error_match):
|
||||||
|
resolve_job_kind(command, is_pull_request=is_pull_request)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -52,10 +80,6 @@ def test_rejects_wrong_location() -> None:
|
|||||||
[
|
[
|
||||||
("/agent iterate", ""),
|
("/agent iterate", ""),
|
||||||
("/agent iterate refine tests", "refine tests"),
|
("/agent iterate refine tests", "refine tests"),
|
||||||
(
|
|
||||||
"/agent iterate\n\nkeep the API stable\nlimit changes to the parser",
|
|
||||||
"keep the API stable\nlimit changes to the parser",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_iterate_accepts_optional_message(body: str, message: str) -> None:
|
def test_iterate_accepts_optional_message(body: str, message: str) -> None:
|
||||||
@@ -63,5 +87,3 @@ def test_iterate_accepts_optional_message(body: str, message: str) -> None:
|
|||||||
assert command is not None
|
assert command is not None
|
||||||
assert command.name is CommandName.ITERATE
|
assert command.name is CommandName.ITERATE
|
||||||
assert command.message == message
|
assert command.message == message
|
||||||
assert resolve_job_kind(command, is_pull_request=False) is JobKind.ITERATE_PLAN
|
|
||||||
assert resolve_job_kind(command, is_pull_request=True) is JobKind.ITERATE_IMPLEMENT
|
|
||||||
|
|||||||
+118
-41
@@ -1,61 +1,138 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from agentci.config import Settings
|
from agentci.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
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", "accepted", "rejected"),
|
||||||
|
[
|
||||||
|
("plan_review_rounds", (1, 20), (0, 21)),
|
||||||
|
("implement_review_rounds", (1, 20), (0, 21)),
|
||||||
|
("turn_timeout_seconds", (60,), (59,)),
|
||||||
|
("install_script_timeout_seconds", (1,), (0,)),
|
||||||
|
("worker_poll_seconds", (0.1,), (0.09,)),
|
||||||
|
("max_concurrent_jobs", (1, 32), (0, 33)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_enforces_documented_numeric_boundaries(
|
||||||
|
field: str,
|
||||||
|
accepted: tuple[int | float, ...],
|
||||||
|
rejected: tuple[int | float, ...],
|
||||||
|
) -> None:
|
||||||
|
for value in accepted:
|
||||||
|
assert getattr(settings(**{field: value}), field) == value
|
||||||
|
for value in rejected:
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
settings = Settings(
|
("value", "expected"),
|
||||||
_env_file=None, # type: ignore[call-arg]
|
[
|
||||||
install_scripts="",
|
(" python, dotnet, company-tools, ", ["python", "dotnet", "company-tools"]),
|
||||||
|
("", []),
|
||||||
|
(None, []),
|
||||||
|
([], []),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
assert settings.install_scripts == []
|
def test_normalizes_install_scripts(value: object, expected: list[str]) -> None:
|
||||||
|
assert settings(install_scripts=value).install_scripts == expected
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_research_variant_to_high(monkeypatch) -> None:
|
def test_agent_defaults_select_expected_capacity_and_research_models() -> None:
|
||||||
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
|
value = settings()
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
|
||||||
assert settings.research_variant == "high"
|
|
||||||
|
|
||||||
|
assert value.max_concurrent_jobs == 2
|
||||||
def test_defaults_explore_agent_to_luna_low() -> None:
|
assert (value.explore_model, value.explore_variant) == (
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
"openai/gpt-5.6-luna",
|
||||||
assert settings.explore_model == "openai/gpt-5.6-luna"
|
"low",
|
||||||
assert settings.explore_variant == "low"
|
)
|
||||||
|
assert value.research_variant == "high"
|
||||||
|
|
||||||
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 +140,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"})
|
||||||
|
|||||||
+180
-6
@@ -1,8 +1,12 @@
|
|||||||
from agentci.adapters.gitea_models import CommentInfo, IssueInfo
|
from typing import cast
|
||||||
from agentci.workflows.context import ContextBuilder
|
|
||||||
|
from agentci.engine.repository import Repository
|
||||||
|
from agentci.integrations.gitea.client import Gitea
|
||||||
|
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||||
|
from agentci.workflows.context import build_issue_context, build_pull_request_context
|
||||||
|
|
||||||
|
|
||||||
class FakeGitea:
|
class FakeIssueGitea:
|
||||||
async def issue(self, *_args):
|
async def issue(self, *_args):
|
||||||
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
|
return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open")
|
||||||
|
|
||||||
@@ -13,15 +17,185 @@ 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 PopulatedPullRequestGitea:
|
||||||
|
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(
|
||||||
|
1,
|
||||||
|
"alice",
|
||||||
|
"First timeline comment: Please add a test.",
|
||||||
|
"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: One issue remains.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "src/widget.py",
|
||||||
|
"new_position": 12,
|
||||||
|
"body": "Handle the empty value.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def pull_commits(self, *_args):
|
||||||
|
return [
|
||||||
|
{"sha": "111111111111aaaa", "commit": {"message": "First commit"}},
|
||||||
|
{"sha": "222222222222bbbb", "commit": {"message": "Second commit"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
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_ordered_feedback_reviews_and_commits() -> None:
|
||||||
|
pull, context = await build_pull_request_context(
|
||||||
|
cast(Gitea, PopulatedPullRequestGitea()), "org", "repo", 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pull == 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",
|
||||||
|
)
|
||||||
|
assert "Please add a test." in context
|
||||||
|
assert "One issue remains." in context
|
||||||
|
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
|
||||||
|
assert "`src/widget.py:12`: Handle the empty value." 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
|
||||||
|
|||||||
+101
-41
@@ -3,13 +3,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agentci.adapters.development import (
|
from agentci.integrations.development import (
|
||||||
DevelopmentEnvironment,
|
DevelopmentEnvironment,
|
||||||
DevelopmentEnvironmentError,
|
DevelopmentEnvironmentError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def environment(
|
def development_environment(
|
||||||
tmp_path: Path, scripts: list[str], *, timeout_seconds: int = 5
|
tmp_path: Path, scripts: list[str], *, timeout_seconds: int = 5
|
||||||
) -> DevelopmentEnvironment:
|
) -> DevelopmentEnvironment:
|
||||||
scripts_dir = tmp_path / "scripts"
|
scripts_dir = tmp_path / "scripts"
|
||||||
@@ -24,22 +24,29 @@ def environment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def script(path: Path, body: str) -> None:
|
def shell_script(path: Path, body: str, *, executable: bool = True) -> None:
|
||||||
path.write_text(f"#!/bin/sh\nset -eu\n{body}\n")
|
path.write_text(f"#!/bin/sh\nset -eu\n{body}\n")
|
||||||
path.chmod(0o755)
|
path.chmod(0o755 if executable else 0o644)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "workspace"
|
||||||
|
path.mkdir()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
async def test_runs_custom_scripts_in_order_with_sanitized_environment(
|
async def test_runs_custom_scripts_in_order_with_sanitized_environment(
|
||||||
tmp_path, monkeypatch
|
tmp_path: Path,
|
||||||
|
workspace: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
development = development_environment(tmp_path, ["first", "second"])
|
||||||
workspace.mkdir()
|
shell_script(
|
||||||
development = environment(tmp_path, ["first", "second"])
|
|
||||||
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")
|
shell_script(development.scripts_dir / "second", "printf 'second\\n' >> order")
|
||||||
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
|
monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak")
|
||||||
|
|
||||||
await development.prepare(workspace)
|
await development.prepare(workspace)
|
||||||
@@ -51,24 +58,16 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment(
|
|||||||
assert (tmp_path / "tools" / "bin").is_dir()
|
assert (tmp_path / "tools" / "bin").is_dir()
|
||||||
|
|
||||||
|
|
||||||
async def test_supplied_script_names_use_the_same_directory(tmp_path) -> None:
|
async def test_runs_non_executable_shell_script_from_bind_mount(
|
||||||
workspace = tmp_path / "workspace"
|
tmp_path: Path,
|
||||||
workspace.mkdir()
|
workspace: Path,
|
||||||
development = environment(tmp_path, ["python"])
|
) -> None:
|
||||||
script(development.scripts_dir / "python", "printf 'python\\n' > selected")
|
development = development_environment(tmp_path, ["mounted"])
|
||||||
|
shell_script(
|
||||||
await development.prepare(workspace)
|
development.scripts_dir / "mounted",
|
||||||
|
"printf 'mounted\\n' > selected",
|
||||||
assert (workspace / "selected").read_text() == "python\n"
|
executable=False,
|
||||||
|
)
|
||||||
|
|
||||||
async def test_runs_non_executable_shell_script_from_bind_mount(tmp_path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
development = environment(tmp_path, ["mounted"])
|
|
||||||
mounted = development.scripts_dir / "mounted"
|
|
||||||
mounted.write_text("#!/bin/sh\nprintf 'mounted\\n' > selected\n")
|
|
||||||
mounted.chmod(0o644)
|
|
||||||
|
|
||||||
await development.prepare(workspace)
|
await development.prepare(workspace)
|
||||||
|
|
||||||
@@ -76,8 +75,8 @@ async def test_runs_non_executable_shell_script_from_bind_mount(tmp_path) -> Non
|
|||||||
|
|
||||||
|
|
||||||
async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None:
|
async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None:
|
||||||
development = environment(tmp_path, ["shared"])
|
development = development_environment(tmp_path, ["shared"])
|
||||||
script(development.scripts_dir / "shared", "true")
|
shell_script(development.scripts_dir / "shared", "true")
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
release = asyncio.Event()
|
release = asyncio.Event()
|
||||||
active = 0
|
active = 0
|
||||||
@@ -102,21 +101,82 @@ async def test_serializes_concurrent_preparation(tmp_path, monkeypatch) -> None:
|
|||||||
assert maximum_active == 1
|
assert maximum_active == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_reports_script_failure_output(tmp_path) -> None:
|
async def test_reports_missing_install_script(tmp_path: Path, workspace: Path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
development = development_environment(tmp_path, ["missing"])
|
||||||
workspace.mkdir()
|
|
||||||
development = environment(tmp_path, ["broken"])
|
with pytest.raises(
|
||||||
script(development.scripts_dir / "broken", "printf 'failed detail' >&2; exit 7")
|
DevelopmentEnvironmentError,
|
||||||
|
match=r"Install script 'missing' was not found",
|
||||||
|
):
|
||||||
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stops_after_failure_and_reports_output(
|
||||||
|
tmp_path: Path,
|
||||||
|
workspace: Path,
|
||||||
|
) -> None:
|
||||||
|
development = development_environment(tmp_path, ["first", "second"])
|
||||||
|
shell_script(
|
||||||
|
development.scripts_dir / "first",
|
||||||
|
"printf 'first\\n' > first-ran; printf 'failed detail' >&2; exit 7",
|
||||||
|
)
|
||||||
|
shell_script(development.scripts_dir / "second", "printf 'second\\n' > second-ran")
|
||||||
|
|
||||||
with pytest.raises(DevelopmentEnvironmentError, match="exited with 7: failed detail"):
|
with pytest.raises(DevelopmentEnvironmentError, match="exited with 7: failed detail"):
|
||||||
await development.prepare(workspace)
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
assert (workspace / "first-ran").read_text() == "first\n"
|
||||||
|
assert not (workspace / "second-ran").exists()
|
||||||
|
|
||||||
async def test_times_out_install_script(tmp_path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
async def test_failure_output_keeps_only_bounded_tail(
|
||||||
workspace.mkdir()
|
tmp_path: Path,
|
||||||
development = environment(tmp_path, ["slow"], timeout_seconds=1)
|
workspace: Path,
|
||||||
script(development.scripts_dir / "slow", "exec sleep 10")
|
) -> None:
|
||||||
|
development = development_environment(tmp_path, ["verbose"])
|
||||||
|
shell_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,
|
||||||
|
workspace: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
development = development_environment(tmp_path, ["broken"])
|
||||||
|
shell_script(development.scripts_dir / "broken", "true")
|
||||||
|
|
||||||
|
async def create_subprocess_exec(*_args, **_kwargs):
|
||||||
|
raise OSError("exec unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agentci.integrations.development.asyncio.create_subprocess_exec",
|
||||||
|
create_subprocess_exec,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
DevelopmentEnvironmentError,
|
||||||
|
match="Could not run install script 'broken': exec unavailable",
|
||||||
|
) as raised:
|
||||||
|
await development.prepare(workspace)
|
||||||
|
|
||||||
|
assert isinstance(raised.value.__cause__, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_times_out_install_script(tmp_path: Path, workspace: Path) -> None:
|
||||||
|
development = development_environment(tmp_path, ["slow"], timeout_seconds=1)
|
||||||
|
shell_script(development.scripts_dir / "slow", "exec sleep 10")
|
||||||
|
|
||||||
with pytest.raises(DevelopmentEnvironmentError, match="exceeded 1 seconds"):
|
with pytest.raises(DevelopmentEnvironmentError, match="exceeded 1 seconds"):
|
||||||
await development.prepare(workspace)
|
await development.prepare(workspace)
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.integrations.git import Git, GitError
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stdout: bytes = b"",
|
||||||
|
stderr: bytes = b"",
|
||||||
|
returncode: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
async def communicate(self) -> tuple[bytes, bytes]:
|
||||||
|
return self.stdout, self.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessRecorder:
|
||||||
|
def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None:
|
||||||
|
self.outcomes = list(outcomes)
|
||||||
|
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess:
|
||||||
|
self.calls.append((args, kwargs))
|
||||||
|
outcome = self.outcomes.pop(0)
|
||||||
|
if isinstance(outcome, OSError):
|
||||||
|
raise outcome
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
@property
|
||||||
|
def commands(self) -> list[tuple[Any, ...]]:
|
||||||
|
return [args for args, _ in self.calls]
|
||||||
|
|
||||||
|
|
||||||
|
def git_client(tmp_path: Path) -> Git:
|
||||||
|
return Git(
|
||||||
|
gitea_url="https://git.example.test/",
|
||||||
|
username="agent-user",
|
||||||
|
token="secret-token",
|
||||||
|
askpass_path=tmp_path / "askpass.sh",
|
||||||
|
commit_name="Agent CI",
|
||||||
|
commit_email="agent@example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_recorder(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
outcomes: Sequence[FakeProcess | OSError],
|
||||||
|
) -> SubprocessRecorder:
|
||||||
|
recorder = SubprocessRecorder(outcomes)
|
||||||
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", recorder)
|
||||||
|
return recorder
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clone_uses_authenticated_remote_and_returns_head(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
for name in (
|
||||||
|
"GIT_ASKPASS",
|
||||||
|
"GIT_TERMINAL_PROMPT",
|
||||||
|
"AGENTCI_GIT_USERNAME",
|
||||||
|
"AGENTCI_GIT_PASSWORD",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(stdout=b"abc123\n")],
|
||||||
|
)
|
||||||
|
destination = tmp_path / "workspaces" / "repo"
|
||||||
|
|
||||||
|
sha = await git_client(tmp_path).clone("org", "repo", "main", destination)
|
||||||
|
|
||||||
|
assert sha == "abc123"
|
||||||
|
assert destination.parent.is_dir()
|
||||||
|
assert recorder.commands == [
|
||||||
|
(
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch",
|
||||||
|
"main",
|
||||||
|
"--single-branch",
|
||||||
|
"https://git.example.test/org/repo.git",
|
||||||
|
str(destination),
|
||||||
|
),
|
||||||
|
("git", "rev-parse", "HEAD"),
|
||||||
|
]
|
||||||
|
clone_kwargs = recorder.calls[0][1]
|
||||||
|
assert clone_kwargs["cwd"] == destination.parent
|
||||||
|
assert clone_kwargs["stdout"] is asyncio.subprocess.PIPE
|
||||||
|
assert clone_kwargs["stderr"] is asyncio.subprocess.PIPE
|
||||||
|
assert (
|
||||||
|
clone_kwargs["env"]
|
||||||
|
| {
|
||||||
|
"GIT_ASKPASS": str(tmp_path / "askpass.sh"),
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"AGENTCI_GIT_USERNAME": "agent-user",
|
||||||
|
"AGENTCI_GIT_PASSWORD": "secret-token",
|
||||||
|
}
|
||||||
|
== clone_kwargs["env"]
|
||||||
|
)
|
||||||
|
current_sha_environment = recorder.calls[1][1]["env"]
|
||||||
|
assert "AGENTCI_GIT_PASSWORD" not in current_sha_environment
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sync_branch_resets_and_cleans_before_returning_sha(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[FakeProcess(), FakeProcess(), FakeProcess(), FakeProcess(stdout=b"new-sha\n")],
|
||||||
|
)
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
|
||||||
|
sha = await git_client(tmp_path).sync_branch(workspace, "feature")
|
||||||
|
|
||||||
|
assert sha == "new-sha"
|
||||||
|
assert recorder.commands == [
|
||||||
|
(
|
||||||
|
"git",
|
||||||
|
"fetch",
|
||||||
|
"origin",
|
||||||
|
"refs/heads/feature:refs/remotes/origin/feature",
|
||||||
|
),
|
||||||
|
("git", "reset", "--hard", "origin/feature"),
|
||||||
|
("git", "clean", "-fd"),
|
||||||
|
("git", "rev-parse", "HEAD"),
|
||||||
|
]
|
||||||
|
assert all(call[1]["cwd"] == workspace for call in recorder.calls)
|
||||||
|
assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_local_worktree_commands_and_status_mapping(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
recorder = install_recorder(
|
||||||
|
monkeypatch,
|
||||||
|
[
|
||||||
|
FakeProcess(),
|
||||||
|
FakeProcess(stdout=b" M src/app.py\n"),
|
||||||
|
FakeProcess(stdout=b" \n"),
|
||||||
|
FakeProcess(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
workspace = tmp_path / "repo"
|
||||||
|
git = git_client(tmp_path)
|
||||||
|
|
||||||
|
await git.create_branch(workspace, "agent/issue-1")
|
||||||
|
dirty = await git.has_changes(workspace)
|
||||||
|
clean = await git.has_changes(workspace)
|
||||||
|
await git.diff_check(workspace)
|
||||||
|
|
||||||
|
assert dirty is True
|
||||||
|
assert clean is False
|
||||||
|
assert recorder.commands == [
|
||||||
|
("git", "switch", "-c", "agent/issue-1"),
|
||||||
|
("git", "status", "--porcelain"),
|
||||||
|
("git", "status", "--porcelain"),
|
||||||
|
("git", "diff", "--check"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user