15 Commits
134 changed files with 11018 additions and 3022 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+11 -8
View File
@@ -1,23 +1,26 @@
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
AGENTCI_PLAN_MODEL=gpt-5.6-sol OPENCODE_SERVER_USERNAME=opencode
AGENTCI_PLAN_REASONING=medium AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
AGENTCI_IMPLEMENT_MODEL=gpt-5.6-sol AGENTCI_PLAN_VARIANT=
AGENTCI_IMPLEMENT_REASONING=high AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
AGENTCI_RESEARCH_MODEL=gpt-5.6-luna AGENTCI_IMPLEMENT_VARIANT=
AGENTCI_RESEARCH_REASONING=high AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna
AGENTCI_EXPLORE_VARIANT=low
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna
AGENTCI_RESEARCH_VARIANT=high
# Optional; Context7 works without a key at lower rate limits. # Optional; Context7 works without a key at lower rate limits.
AGENTCI_CONTEXT7_API_KEY= AGENTCI_CONTEXT7_API_KEY=
AGENTCI_PLAN_REVIEW_ROUNDS=4 AGENTCI_PLAN_REVIEW_ROUNDS=4
AGENTCI_IMPLEMENT_REVIEW_ROUNDS=3 AGENTCI_IMPLEMENT_REVIEW_ROUNDS=3
AGENTCI_TURN_TIMEOUT_SECONDS=3600 AGENTCI_TURN_TIMEOUT_SECONDS=3600
AGENTCI_MAX_CONCURRENT_JOBS=2
# Comma-delimited built-in or custom script names, for example: python,dotnet,company-tools # Comma-delimited built-in or custom script names, for example: python,dotnet,company-tools
AGENTCI_INSTALL_SCRIPTS= AGENTCI_INSTALL_SCRIPTS=
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900 AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
AGENTCI_PYTHON_VERSION=3.13 AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0 AGENTCI_DOTNET_CHANNEL=10.0
CODEX_VERSION=0.144.6
CODEGRAPH_VERSION=1.3.1
+41
View File
@@ -0,0 +1,41 @@
name: Publish container image
on:
push:
branches:
- master
env:
REGISTRY: git.krtss.de
IMAGE_NAME: git.krtss.de/stanponomarev/agentci
jobs:
publish:
name: Build and push
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
pull: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
labels: |
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="~/repos/agentci/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/agentci.iml" filepath="$PROJECT_DIR$/.idea/agentci.iml" />
</modules>
</component>
</project>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PyToolsState">
<option name="tools">
<map>
<entry key="pyright">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
<entry key="ruff">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
</map>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+132
View File
@@ -0,0 +1,132 @@
# Agent CI repository instructions
## Scope
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
recovery, deployment, or the security boundary.
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
the requested change merely for consistency.
## Repository map
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
task claiming, and the `JobRun` event interface.
- `src/agentci/application/`: runtime composition, durable control/job queues, task handlers,
recovery, and comment reconciliation.
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
contracts; keep these concerns outside Python orchestration.
- `src/agentci/migrations/`: ordered SQLite migrations.
- `tests/`: pytest suite, generally organized by module or behavior.
- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and
trusted runtime configuration.
## Setup and commands
Use Python 3.13 or newer and `uv`. Run commands from the repository root.
```sh
uv sync
```
Run the narrowest relevant test while iterating:
```sh
uv run pytest tests/test_<area>.py
uv run pytest tests/test_<area>.py::test_<behavior>
uv run pytest -k '<expression>'
```
Run the standard Python checks before completion:
```sh
uv run ruff check .
uv run pyright
uv run pytest
```
Coverage is diagnostic and has no required threshold:
```sh
uv run pytest --cov=agentci --cov-branch
```
For deployment-related changes, also run:
```sh
docker compose config
```
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
OpenCode configuration. It may require network access and takes longer than the normal checks.
## Engineering conventions
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
rather than mutating state in place.
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
events and task requests.
- Apply state transitions, event persistence, and resulting task creation atomically through
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
deterministic comment reconciliation.
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
failed and its sessions are aborted rather than replayed.
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
log credentials, secret contents, authorization headers, or private prompt data.
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
applied.
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
hand.
## Testing conventions
- Add regression tests for behavior changes, especially reducer transitions, persistence and
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
asynchronous.
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
error text rather than private implementation details.
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
or network access.
## Security and operational boundaries
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime
databases, cloned private repositories, or Docker volume contents. Do not send secrets or private
repository content to external search or research services.
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
secret-file loading.
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
or external skills, expose its server to the host, add privileged/capability settings, or add
writable host mounts without an explicit security review.
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
credentials to them.
- Git pushes performed by Agent CI must remain non-forcing.
- Do not run provider authentication, start/restart deployment services, modify production data, or
perform other live operations unless the user explicitly requests it.
- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`,
`.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`.
## Completion expectations
- Run focused tests first, then all applicable standard checks. If a check cannot run, report the
exact command and reason.
- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands,
configuration, deployment, recovery behavior, or security assumptions.
- Summarize behavior changes, validation performed, and any migration, compatibility, or security
implications in the final response or pull-request description.
+33 -22
View File
@@ -1,12 +1,14 @@
ARG TEA_VERSION=0.14.2 ARG TEA_VERSION=0.14.2
FROM node:24-bookworm-slim AS codex FROM node:24-bookworm-slim AS agent-tools
ARG CODEX_VERSION=0.144.6
ARG CODEGRAPH_VERSION=1.3.1 ARG CODEGRAPH_VERSION=1.3.1
ARG AGENTCI_OPENCODE_VERSION=^1
RUN npm install --global \ RUN npm install --global \
"@openai/codex@${CODEX_VERSION}" \ "opencode-ai@${AGENTCI_OPENCODE_VERSION}" \
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}" "@colbymchenry/codegraph@${CODEGRAPH_VERSION}" \
&& version="$(opencode --version)" \
&& case "$version" in 1.*) ;; *) echo "Expected OpenCode 1.x, got $version" >&2; exit 1;; esac
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
@@ -19,30 +21,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_NO_DEV=1 \ UV_NO_DEV=1 \
CODEGRAPH_TELEMETRY=0 \ CODEGRAPH_TELEMETRY=0 \
CODEX_HOME=/var/lib/codex \
XDG_CONFIG_HOME=/run/agentci \ XDG_CONFIG_HOME=/run/agentci \
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN apt-get update \ RUN apt-get update \
&& apt-get install --yes --no-install-recommends \ && apt-get install --yes --no-install-recommends \
adduser bubblewrap ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \ adduser ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
libicu72 libssl3 libstdc++6 zlib1g \ libicu72 libssl3 libstdc++6 zlib1g \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \ && /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \ && mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \
&& chown agentci:agentci /run/agentci \ /etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \
&& chmod u+s /usr/bin/bwrap && chmod 0555 /etc/opencode/home /etc/opencode/home/.opencode \
/etc/opencode/xdg /etc/opencode/xdg/opencode \
&& chown agentci:agentci /run/agentci
COPY --from=uv /uv /uvx /usr/local/bin/ COPY --from=uv /uv /uvx /usr/local/bin/
COPY --from=tea /bin/tea /usr/local/bin/tea COPY --from=tea /bin/tea /usr/local/libexec/tea
COPY --from=codex /usr/local/bin/node /usr/local/bin/node COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node
COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode
COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \ RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
&& ln -s \
/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \
/usr/local/bin/codex-linux-sandbox \
&& ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
/usr/local/bin/codegraph /usr/local/bin/codegraph
WORKDIR /opt/agentci WORKDIR /opt/agentci
@@ -50,16 +49,28 @@ COPY pyproject.toml uv.lock README.md ./
COPY src ./src COPY src ./src
COPY scripts ./scripts COPY scripts ./scripts
COPY install-scripts /etc/agentci/install-scripts COPY install-scripts /etc/agentci/install-scripts
COPY codex/config.toml /etc/codex/config.toml COPY opencode /etc/opencode
RUN chmod 0755 \ RUN chmod 0755 \
/opt/agentci/scripts/entrypoint.sh \ /opt/agentci/scripts/entrypoint.sh \
/opt/agentci/scripts/gitea-askpass.sh \ /opt/agentci/scripts/gitea-askpass.sh \
/usr/local/bin/tea \ /opt/agentci/scripts/tea.sh \
/usr/local/bin/codex \ /usr/local/libexec/tea \
/usr/local/bin/opencode \
/usr/local/bin/codegraph \ /usr/local/bin/codegraph \
&& ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \
&& uv sync --frozen --no-dev \ && uv sync --frozen --no-dev \
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex && chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/opencode \
&& opencode --version \
&& OPENCODE_CONFIG=/etc/opencode/opencode.json \
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
OPENCODE_PURE=1 \
AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna \
AGENTCI_EXPLORE_VARIANT=low \
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \
AGENTCI_RESEARCH_VARIANT=high \
CONTEXT7_API_KEY= \
opencode debug config >/dev/null
USER agentci USER agentci
EXPOSE 8080 EXPOSE 8080
+141 -81
View File
@@ -1,8 +1,25 @@
# Agent CI # Agent CI
Agent CI is a private Gitea webhook host that turns issue and pull-request Agent CI is a private Gitea webhook host that turns issue and pull-request comments into resumable
comments into resumable Codex planning and implementation workflows. It runs as OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
one persistent Docker Compose service 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
@@ -15,68 +32,113 @@ one persistent Docker Compose service on the same Docker network as Gitea.
| PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. | | PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. |
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. | | PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
Anyone who can comment on an issue or pull request can enqueue commands. Each The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Commands
command gets separate queued and started comments. Final plans, PR results, are durably sequenced when their webhook arrives, then authorized and executed in receive order per
failures, and remaining review findings are posted separately. issue or pull request. Up to `AGENTCI_MAX_CONCURRENT_JOBS` unrelated targets execute concurrently;
the default is two. Each command gets one Gitea comment, which is reconciled asynchronously through
queued, running, and terminal states. Deleted comments are rediscovered by their hidden marker or
recreated.
## Deploy ## Deploy
1. Create a Gitea bot user with repository read/write access and an API token. 1. Create a Gitea bot user with repository read/write access and an API token.
2. Copy `.env.example` to `.env` and set the external network and internal 2. Copy `.env.example` to `.env` and set the external network, Gitea URL, and provider-qualified
Gitea URL. OpenCode models.
3. Create `secrets/gitea_token` containing the bot token and 3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
`secrets/webhook_secret` containing a high-entropy webhook secret. `secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
4. Build and start the service: 4. Log in to the Gitea container registry with a personal access token, then pull the image:
```sh ```sh
docker compose up --build -d docker login git.krtss.de
docker compose pull
``` ```
5. Authenticate Codex interactively in the persistent container: 5. Authenticate the configured OpenCode providers before starting the persistent server:
```sh ```sh
docker compose exec agentci codex login --device-auth docker compose run --rm opencode opencode auth login
docker compose exec agentci codex login status docker compose run --rm opencode opencode auth list
``` ```
6. In Gitea, create a JSON webhook targeting 6. Start the services:
`http://agentci:8080/webhooks/gitea`. Set the same secret and subscribe to
issue comments, PR timeline comments, and PR review comments.
`/health/live` reports process health. `/health/ready` returns 503 until Codex ```sh
authentication is usable. The worker leaves jobs queued while authentication is docker compose up --no-build -d
missing. ```
## Configuration 7. 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.
Model, reasoning effort, review-pass counts, bot identity, branch prefix, and OpenCode caches provider state. After adding or changing authentication on an already running
turn timeout use `AGENTCI_` environment variables. Defaults are shown in deployment, run the one-off `auth login` command above and then `docker compose restart opencode`.
`.env.example`. Gitea credentials and webhook secrets are intentionally
file-based Compose secrets.
Every planning and implementation session can delegate external research to a `/health/live` reports process health. `/health/ready` returns 503 until the OpenCode server is
read-only `research` subagent. It defaults to `gpt-5.6-luna` with high reasoning healthy and every configured model exists, supports tool calls, accepts its configured variant, and
and has public network access, live web search, Context7 documentation lookup, has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
and `gh_grep` public GitHub code search. Configure its model and effort with
`AGENTCI_RESEARCH_MODEL` and `AGENTCI_RESEARCH_REASONING`. Context7 works
without authentication at lower rate limits; set the optional
`AGENTCI_CONTEXT7_API_KEY` for authenticated usage. The key is passed only to
Codex's Context7 MCP transport and is excluded from agent shell environments.
Planning, implementation, and all review sessions also receive the local ### Image publishing
CodeGraph MCP server for repository structure, symbol relationships, and change
impact. Agent CI initializes or refreshes the index before every Codex turn and
locally excludes `.codegraph/` from Git. The research subagent intentionally
does not receive CodeGraph.
### Development environments Every push to `master` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both
`git.krtss.de/stanponomarev/agentci:latest` and
`git.krtss.de/stanponomarev/agentci:<full-commit-sha>`. Configure these repository Actions secrets
before the first run:
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development | Secret | Value |
environment installers. The supplied `python` and `dotnet` scripts install only | --- | --- |
their runtimes; they are ordinary scripts that can be replaced or removed. | `REGISTRY_USERNAME` | Username that owns the package or can write packages for the owner. |
Implementation agents remain responsible for restoring project dependencies | `REGISTRY_TOKEN` | Personal access token with package write permission. |
and selecting build/test commands. Configure the supplied scripts with
`AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`: 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
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
different providers. Optional `AGENTCI_PLAN_VARIANT` and `AGENTCI_IMPLEMENT_VARIANT` values are
passed directly to OpenCode for providers that support variants. `AGENTCI_RESEARCH_VARIANT`
configures the research subagent and defaults to `high`. `AGENTCI_EXPLORE_MODEL` and
`AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to
`openai/gpt-5.6-luna` with `low`.
The `AGENTCI_OPENCODE_VERSION` Docker build argument controls the npm version or range installed into
the image and defaults to `^1`. The build verifies that the resolved version is still OpenCode 1.x
and prints it. Docker may reuse the cached installation layer until the configured version or build
inputs change. Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it
is built.
The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
Repository-local OpenCode config and external plugins are disabled so a clone cannot replace the
service policy. OpenCode's default plugins remain enabled for provider authentication. Its scanned
home and global configuration paths are root-owned and read-only, and external skill discovery is
disabled, so an agent cannot persist instructions for later repositories. CodeGraph, Context7, and
`gh_grep` are configured as MCP servers. Set `AGENTCI_CONTEXT7_API_KEY` to raise Context7 rate
limits.
The `research` subagent can only use Exa web search, Context7, and the `gh_grep` public-code search
MCP. All filesystem, shell, editing, task, and other tools are denied for that agent. Exa is enabled
with `OPENCODE_ENABLE_EXA=1`. Agent CI initializes or refreshes CodeGraph before every parent turn
and locally excludes `.codegraph/` from Git.
### Security boundary
OpenCode does not provide an OS sandbox. Its default permission system controls agent tools and
approval requests, while Docker limits what the non-root process can reach. Any shell command that
OpenCode permits still has the Unix-level access of that container user, so environment filtering
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,
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only
host bind mount is the read-only installer directory; there are no
writable host filesystem mounts. 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
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development environment installers.
The supplied `python` and `dotnet` scripts install only their runtimes; they can be replaced or
removed. Configure them with `AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
```env ```env
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
@@ -84,44 +146,38 @@ AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0 AGENTCI_DOTNET_CHANNEL=10.0
``` ```
Every name resolves to an executable file in `install-scripts/`, mounted Every name resolves to a file in `install-scripts/`, mounted read-only at
read-only at `/etc/agentci/install-scripts`. Names cannot contain paths and `/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers
duplicates are rejected. See `install-scripts/README.md` for the script contract. run after each implementation clone or branch sync and fail the job on an unknown script, timeout,
or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain
trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or
modify them through shell commands permitted by its active agent policy. See
`install-scripts/README.md` for the script contract.
Installers run in order after each implementation clone or branch sync and fail Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
the job on an unknown script, timeout, or non-zero exit. They receive no AgentCI keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
or Gitea secret values in their environment, but remain trusted operator code Git commands itself.
running as the service user. Tools persist under `/var/lib/agentci/dev-tools`;
its `bin` directory is added to implementation agents' `PATH` with read-only
sandbox access. The agents can use those tools for builds and validation, but
Agent CI does not impose host-side build commands.
Planning/review commands can only read their workflow clone and have no shell
network access. Implementation/fix commands can edit the clone but cannot
modify `.git`; they can reach public internet destinations while private and
loopback destinations remain blocked. Codex's interactive Git trust check is
skipped because every turn runs non-interactively against a service-owned clone;
the configured filesystem and network permissions still apply. Git credentials
exist only in the service-owned clone/push subprocess and are not inherited by
Codex turns. The image and Compose capability/security settings let the non-root
service create Codex's nested `bwrap` sandbox. They follow Codex's secure
devcontainer pattern instead of making the service container privileged. Do not
remove Codex's configured filesystem and network restrictions. In particular,
`systempaths=unconfined` removes Docker's outer masked `/proc` subpaths so the
nested user/PID namespace can mount its own procfs. Runtimes such as CoreCLR
require `/proc/self/maps`; Codex still controls visibility through the fresh
procfs and its filesystem policy.
## State and recovery ## State and recovery
The `agentci_data` volume contains SQLite, persistent workflow clones, and The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes.
installed development runtimes. The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable
`codex_home` contains login state and resumable Codex sessions. Both are kept sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
indefinitely and should be backed up together. `opencode_home`. Back up both persistent volumes together.
Queued jobs survive restart. An in-progress job is marked failed after restart SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
instead of being replayed, because replaying a partially completed model turn transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
could duplicate changes. Git pushes are never forced. than reducer state. Control effects retry with bounded backoff. A delayed authorization blocks later
workflow execution for the same issue or pull request, but not other targets or later control work.
Development environment installers remain serialized because they share the persistent tools
directory.
The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for
rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a
new plan or implementation. Queued jobs survive restart. A restart before `JobStarted` returns the
task to the queue; after `JobStarted`, the job is failed, its sessions are aborted, and execution is
never replayed because a partial model turn may already have changed files. Git pushes are never
forced.
## Development ## Development
@@ -132,7 +188,11 @@ uv sync
uv run ruff check . uv run ruff check .
uv run pyright uv run pyright
uv run pytest uv run pytest
uv run pytest --cov=agentci --cov-branch
docker compose config
``` ```
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON The coverage command is an opt-in diagnostic report; the regular test run remains the default and
schemas live outside 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.
-99
View File
@@ -1,99 +0,0 @@
cli_auth_credentials_store = "file"
approval_policy = "never"
check_for_update_on_startup = false
web_search = "disabled"
default_permissions = "agentci-read"
developer_instructions = """
A custom research subagent named `research` is available in every workflow. Delegate focused
external research to it when current documentation, web evidence, or public code examples would
materially improve the plan or implementation. Keep repository analysis and edits in the parent.
Use CodeGraph for repository architecture, symbol relationships, and change-impact analysis when
it is useful. It is available to planning, implementation, and review agents, but not research.
"""
[mcp_servers.codegraph]
command = "codegraph"
args = ["serve", "--mcp"]
startup_timeout_sec = 60
tool_timeout_sec = 60
[mcp_servers.codegraph.env]
CODEGRAPH_TELEMETRY = "0"
[agents]
max_threads = 4
max_depth = 1
[shell_environment_policy]
inherit = "core"
exclude = ["*TOKEN*", "*SECRET*", "*KEY*", "AGENTCI_*", "GITEA_*"]
[permissions.agentci-read]
description = "Read a workflow repository without modifying it or using the network."
[permissions.agentci-read.filesystem]
":minimal" = "read"
glob_scan_max_depth = 5
[permissions.agentci-read.filesystem.":workspace_roots"]
"." = "read"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-review]
description = "Review scoped workflow material and query Gitea with tea without editing files."
[permissions.agentci-review.filesystem]
":minimal" = "read"
glob_scan_max_depth = 5
[permissions.agentci-review.filesystem.":workspace_roots"]
"." = "read"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-review.network]
enabled = true
allow_local_binding = false
[permissions.agentci-review.network.domains]
"*" = "allow"
[permissions.agentci-write]
description = "Edit a workflow repository without changing Git metadata."
[permissions.agentci-write.filesystem]
":minimal" = "read"
"/var/lib/agentci/dev-tools" = "read"
glob_scan_max_depth = 5
[permissions.agentci-write.filesystem.":workspace_roots"]
"." = "write"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-write.network]
enabled = true
allow_local_binding = false
[permissions.agentci-write.network.domains]
"*" = "allow"
[permissions.agentci-research]
description = "Read a workflow repository and research public internet sources without editing."
[permissions.agentci-research.filesystem]
":minimal" = "read"
glob_scan_max_depth = 5
[permissions.agentci-research.filesystem.":workspace_roots"]
"." = "read"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-research.network]
enabled = true
allow_local_binding = false
[permissions.agentci-research.network.domains]
"*" = "allow"
+72 -33
View File
@@ -1,41 +1,29 @@
services: services:
agentci: agentci:
build: image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
context: .
args:
CODEX_VERSION: ${CODEX_VERSION:-0.144.6}
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
TEA_VERSION: ${TEA_VERSION:-0.14.2}
restart: unless-stopped restart: unless-stopped
# Codex applies its own bwrap sandbox inside this otherwise unprivileged container. depends_on:
cap_add: opencode:
- SYS_ADMIN condition: service_healthy
- SYS_CHROOT
- SETUID
- SETGID
- SYS_PTRACE
- NET_ADMIN
- NET_RAW
security_opt:
- seccomp=unconfined
- apparmor=unconfined
# Docker's masked /proc paths prevent Codex's user namespace from mounting fresh procfs.
- systempaths=unconfined
environment: environment:
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000} AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci} AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci}
AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI} AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI}
AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost} AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost}
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-gpt-5.6-sol} AGENTCI_OPENCODE_URL: http://opencode:4096
AGENTCI_PLAN_REASONING: ${AGENTCI_PLAN_REASONING:-medium} AGENTCI_OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-gpt-5.6-sol} AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-openai/gpt-5.6-sol}
AGENTCI_IMPLEMENT_REASONING: ${AGENTCI_IMPLEMENT_REASONING:-high} AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-}
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-gpt-5.6-luna} AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol}
AGENTCI_RESEARCH_REASONING: ${AGENTCI_RESEARCH_REASONING:-high} AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-}
AGENTCI_CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-} AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna}
AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}
AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4} AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4}
AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3} AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600} AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600}
AGENTCI_MAX_CONCURRENT_JOBS: ${AGENTCI_MAX_CONCURRENT_JOBS:-2}
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS: ${AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS:-900} AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS: ${AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS:-900}
AGENTCI_INSTALL_SCRIPTS: ${AGENTCI_INSTALL_SCRIPTS:-} AGENTCI_INSTALL_SCRIPTS: ${AGENTCI_INSTALL_SCRIPTS:-}
AGENTCI_PYTHON_VERSION: ${AGENTCI_PYTHON_VERSION:-3.13} AGENTCI_PYTHON_VERSION: ${AGENTCI_PYTHON_VERSION:-3.13}
@@ -43,26 +31,77 @@ services:
secrets: secrets:
- gitea_token - gitea_token
- webhook_secret - webhook_secret
- opencode_server_password
volumes: volumes:
- agentci_data:/var/lib/agentci - ./data/agentci:/var/lib/agentci
- codex_home:/var/lib/codex
- ./install-scripts:/etc/agentci/install-scripts:ro - ./install-scripts:/etc/agentci/install-scripts:ro
tmpfs:
- /run/agentci:mode=1777
expose: expose:
- "8080" - "8080"
networks: networks:
- gitea - gitea
- agentci_control
opencode:
image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
restart: unless-stopped
environment:
HOME: /etc/opencode/home
XDG_CONFIG_HOME: /etc/opencode/xdg
XDG_DATA_HOME: /var/lib/opencode/data
XDG_CACHE_HOME: /var/lib/opencode/cache
XDG_STATE_HOME: /var/lib/opencode/state
OPENCODE_CONFIG: /etc/opencode/opencode.json
OPENCODE_DISABLE_PROJECT_CONFIG: "1"
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1"
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1"
OPENCODE_DISABLE_CLAUDE_CODE: "1"
OPENCODE_DISABLE_AUTOUPDATE: "1"
OPENCODE_ENABLE_EXA: "1"
OPENCODE_PURE: "1"
OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
OPENCODE_SERVER_PASSWORD_FILE: /run/secrets/opencode_server_password
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
AGENTCI_TEA_CONFIG_HOME: /run/agentci
AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna}
AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}
CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-}
PATH: /var/lib/agentci/dev-tools/bin:/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
secrets:
- gitea_token
- opencode_server_password
volumes:
- ./data/agentci:/var/lib/agentci
- ./data/opencode:/var/lib/opencode
tmpfs:
- /run/agentci:mode=1777
expose:
- "4096"
networks:
- gitea
- agentci_control
healthcheck:
test: ["CMD-SHELL", "curl --fail --silent --user \"$${OPENCODE_SERVER_USERNAME}:$$(cat $${OPENCODE_SERVER_PASSWORD_FILE})\" http://127.0.0.1:4096/global/health >/dev/null"]
interval: 10s
timeout: 5s
start_period: 20s
retries: 6
secrets: secrets:
gitea_token: gitea_token:
file: ./secrets/gitea_token file: ./secrets/gitea_token
webhook_secret: webhook_secret:
file: ./secrets/webhook_secret file: ./secrets/webhook_secret
opencode_server_password:
volumes: file: ./secrets/opencode_server_password
agentci_data:
codex_home:
networks: networks:
gitea: gitea:
external: true external: true
name: ${GITEA_NETWORK:-gitea} name: ${GITEA_NETWORK:-gitea}
agentci_control:
internal: true
+3 -4
View File
@@ -18,10 +18,9 @@ files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/b
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.
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state
.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only
normal user home and receive read-only access to installed tools, so runtime caches cannot live so it cannot be used to persist global agent configuration.
beside the SDK.
The configured scripts run in list order after the repository is cloned (or an existing agent PR The configured scripts run in list order after the repository is cloned (or an existing agent PR
branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and
+4 -3
View File
@@ -6,6 +6,7 @@ set -eu
install_dir="$DEV_TOOLS_DIR/dotnet" install_dir="$DEV_TOOLS_DIR/dotnet"
bin_dir="$DEV_TOOLS_DIR/bin" bin_dir="$DEV_TOOLS_DIR/bin"
runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet"
installer=$(mktemp) installer=$(mktemp)
trap 'rm -f "$installer"' EXIT trap 'rm -f "$installer"' EXIT
@@ -25,9 +26,9 @@ PYTHON
printf '%s\n' \ printf '%s\n' \
'#!/bin/sh' \ '#!/bin/sh' \
"export DOTNET_ROOT='$install_dir'" \ "export DOTNET_ROOT='$install_dir'" \
'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \ "export DOTNET_CLI_HOME='$runtime_dir/home'" \
'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \ "export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \
'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \ "export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \
'export HOME="$DOTNET_CLI_HOME"' \ 'export HOME="$DOTNET_CLI_HOME"' \
'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \ 'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \
'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \ 'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \
+6
View File
@@ -0,0 +1,6 @@
# Agent CI runtime
A `research` subagent is available in every workflow. Delegate focused external research when
current documentation, web evidence, or public code examples would materially improve the result.
Keep repository analysis and edits in the parent agent. Use CodeGraph for repository architecture,
symbol relationships, and change-impact analysis when useful.
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"share": "disabled",
"subagent_depth": 1,
"instructions": ["/etc/opencode/AGENTS.md"],
"agent": {
"explore": {
"model": "{env:AGENTCI_EXPLORE_MODEL}",
"variant": "{env:AGENTCI_EXPLORE_VARIANT}"
},
"research": {
"description": "Research current documentation, web evidence, and public code examples.",
"mode": "subagent",
"model": "{env:AGENTCI_RESEARCH_MODEL}",
"variant": "{env:AGENTCI_RESEARCH_VARIANT}",
"permission": {
"*": "deny",
"websearch": "allow",
"context7_*": "allow",
"gh_grep_*": "allow"
},
"prompt": "Research external, current, or unfamiliar technical facts for the parent agent. Use Context7 for library documentation, gh_grep for public-code examples, and web search for primary sources or broader verification. Prefer authoritative sources, report links, distinguish facts from inference, and return a concise evidence-focused summary. Do not include secrets or proprietary source in external queries."
}
},
"mcp": {
"codegraph": {
"type": "local",
"command": ["codegraph", "serve", "--mcp"],
"environment": {
"CODEGRAPH_TELEMETRY": "0"
},
"enabled": true,
"timeout": 60000
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
},
"enabled": true
},
"gh_grep": {
"type": "remote",
"url": "https://mcp.grep.app",
"enabled": true
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agentci" name = "agentci"
version = "0.1.0" version = "0.1.0"
description = "A Gitea webhook host that orchestrates resumable Codex workflows" description = "A Gitea webhook host that orchestrates resumable OpenCode workflows"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
@@ -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",
] ]
+9 -2
View File
@@ -1,12 +1,18 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
python -c ' if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then
export OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE")
fi
if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then
python -c '
import json import json
import os import os
from pathlib import Path from pathlib import Path
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea" config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea"
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.yml" config_path = config_dir / "config.yml"
token_path = Path( token_path = Path(
@@ -22,5 +28,6 @@ login = {
config_path.write_text(json.dumps({"logins": [login], "preferences": {}})) config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
config_path.chmod(0o600) config_path.chmod(0o600)
' '
fi
exec "$@" exec "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}"
exec /usr/local/libexec/tea "$@"
+1 -4
View File
@@ -1,4 +1 @@
"""Gitea-triggered Codex workflow host.""" """Gitea-triggered OpenCode workflow host."""
__version__ = "0.1.0"
+2 -3
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
import uvicorn import uvicorn
from agentci.app import create_app from agentci.api.app import create_app
from agentci.config import Settings from agentci.config.settings import Settings
def main() -> None: def main() -> None:
@@ -19,4 +19,3 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-2
View File
@@ -1,2 +0,0 @@
"""External-system adapters."""
-250
View File
@@ -1,250 +0,0 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
from pathlib import Path
from time import monotonic
from typing import TypeVar
from pydantic import BaseModel
from agentci.adapters.codegraph import CodeGraphClient
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class CodexError(RuntimeError):
pass
class CodexClient:
def __init__(
self,
*,
codex_home: Path,
schemas_dir: Path,
timeout_seconds: int,
research_model: str,
research_reasoning: str,
context7_api_key: str | None,
tools_bin: Path | None = None,
codegraph: CodeGraphClient | None = None,
) -> None:
self.codex_home = codex_home
self.schemas_dir = schemas_dir
self.timeout_seconds = timeout_seconds
self.context7_api_key = context7_api_key
self.tools_bin = tools_bin
self.codegraph = codegraph or CodeGraphClient()
self._write_research_agent(research_model, research_reasoning)
async def login_ready(self) -> bool:
try:
process = await asyncio.create_subprocess_exec(
"codex",
"login",
"status",
env=self._environment(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return False
return await process.wait() == 0
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
reasoning: str,
permission: str,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
"--json",
"--strict-config",
"-C",
str(workspace),
*self._turn_args(model, reasoning, permission, schema_name),
"-",
]
session_id, result = await self._invoke(args, prompt, result_type, workspace=workspace)
if not session_id:
raise CodexError("Codex did not emit a thread.started event")
return session_id, result
async def resume(
self,
*,
session_id: str,
prompt: str,
model: str,
reasoning: str,
permission: str,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
"resume",
"--json",
"--strict-config",
*self._turn_args(model, reasoning, permission, schema_name),
session_id,
"-",
]
_, result = await self._invoke(args, prompt, result_type, workspace=workspace)
return result
def _turn_args(
self,
model: str,
reasoning: str,
permission: str,
schema_name: str,
) -> list[str]:
return [
"--skip-git-repo-check",
"-m",
model,
"-c",
f'model_reasoning_effort="{reasoning}"',
"-c",
f'default_permissions="{permission}"',
"--output-schema",
str(self.schemas_dir / schema_name),
]
async def _invoke(
self, args: list[str], prompt: str, result_type: type[T], *, workspace: Path
) -> tuple[str | None, T]:
started = monotonic()
operation = "codex.resume" if "resume" in args else "codex.start"
log.info("Codex turn started", extra={"operation": operation})
file_descriptor, output_name = tempfile.mkstemp(
prefix="agentci-codex-", suffix=".json"
)
os.close(file_descriptor)
output_path = Path(output_name)
args[-1:-1] = ["--output-last-message", str(output_path)]
try:
process = await asyncio.create_subprocess_exec(
*args,
cwd=workspace,
env=self._environment(),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(prompt.encode()), timeout=self.timeout_seconds
)
except TimeoutError as exc:
process.terminate()
await process.wait()
log.exception(
"Codex turn timed out",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Codex turn exceeded {self.timeout_seconds} seconds") from exc
if process.returncode:
detail = stderr.decode(errors="replace").strip()
log.error(
"Codex turn failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Codex exited with {process.returncode}: {detail[-2000:]}")
session_id = _session_id(stdout.decode(errors="replace"))
text = output_path.read_text() # noqa: ASYNC240 - tiny host-owned result file
result = result_type.model_validate_json(text)
log.info(
"Codex turn completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
return session_id, result
except (OSError, ValueError) as exc:
log.exception(
"Codex result could not be processed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Invalid Codex result: {exc}") from exc
finally:
output_path.unlink(missing_ok=True) # noqa: ASYNC240
def _environment(self) -> dict[str, str]:
allowed = {
"PATH",
"LANG",
"LC_ALL",
"SSL_CERT_FILE",
"CODEX_CA_CERTIFICATE",
"XDG_CONFIG_HOME",
}
environment = {key: value for key, value in os.environ.items() if key in allowed}
if self.tools_bin is not None:
environment["PATH"] = f"{self.tools_bin}:{environment.get('PATH', '')}"
environment["CODEX_HOME"] = str(self.codex_home)
if self.context7_api_key:
environment["CONTEXT7_API_KEY"] = self.context7_api_key
return environment
def _write_research_agent(self, model: str, reasoning: str) -> None:
agents_dir = self.codex_home / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
agent = f'''name = "research"
description = "Research specialist for current docs, web evidence, and public code examples."
model = {json.dumps(model)}
model_reasoning_effort = {json.dumps(reasoning)}
default_permissions = "agentci-research"
web_search = "live"
developer_instructions = """
Research external, current, or unfamiliar technical facts for the parent agent.
Use Context7 for library documentation, gh_grep for real public-code examples, and web search for
primary sources or broader verification. Prefer authoritative sources, report links, distinguish
facts from inference, and return a concise evidence-focused summary. You may inspect the workspace
but must not modify it. Never include secrets or proprietary source in external queries.
Do not use CodeGraph; repository analysis belongs to the parent agent.
"""
[mcp_servers.codegraph]
enabled = false
[mcp_servers.context7]
url = "https://mcp.context7.com/mcp"
[mcp_servers.context7.env_http_headers]
CONTEXT7_API_KEY = "CONTEXT7_API_KEY"
[mcp_servers.gh_grep]
url = "https://mcp.grep.app"
'''
(agents_dir / "research.toml").write_text(agent)
def _session_id(output: str) -> str | None:
for line in output.splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "thread.started":
return str(event["thread_id"])
return None
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
-75
View File
@@ -1,75 +0,0 @@
from __future__ import annotations
import logging
import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TypeVar
T = TypeVar("T")
log = logging.getLogger(__name__)
def now() -> str:
return datetime.now(UTC).isoformat()
class Database:
def __init__(self, database_path: Path, migrations_dir: Path) -> None:
self.database_path = database_path
self.migrations_dir = migrations_dir
async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True)
try:
await self._run(self._initialize_sync)
except Exception:
log.exception(
"database initialization failed", extra={"operation": "database.initialize"}
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def _initialize_sync(self, connection: sqlite3.Connection) -> None:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row[0] for row in connection.execute("SELECT version FROM schema_migrations")
}
for path in sorted(self.migrations_dir.glob("*.sql")):
version = int(path.name.split("_", 1)[0])
if version in applied:
continue
connection.executescript(path.read_text())
connection.execute(
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
(version, now()),
)
async def _update(self, table: str, row_id: str, updates: dict[str, object]) -> None:
if not updates:
return
columns = ", ".join(f"{column}=?" for column in updates)
values = [*updates.values(), row_id]
await self._run(
lambda connection: connection.execute(
f"UPDATE {table} SET {columns} WHERE id=?", values
)
)
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
# Operations are deliberately tiny and serialized by the single worker.
# Avoid a thread pool so SQLite transactions retain deterministic ordering.
with self._connect() as connection:
return operation(connection)
-195
View File
@@ -1,195 +0,0 @@
from __future__ import annotations
import logging
import sqlite3
from agentci.adapters.database import Database, now
from agentci.domain.models import Job, JobKind, JobStatus
log = logging.getLogger(__name__)
class JobStore(Database):
async def record_delivery(self, delivery_id: str, comment_id: int) -> bool:
def record(connection: sqlite3.Connection) -> bool:
try:
connection.execute(
"INSERT INTO deliveries VALUES (?, ?, ?)",
(delivery_id, comment_id, now()),
)
except sqlite3.IntegrityError:
return False
return True
return await self._run(record)
async def enqueue(self, delivery_id: str, job: Job) -> bool:
return await self._run(lambda connection: self._enqueue(connection, delivery_id, job))
@staticmethod
def _enqueue(connection: sqlite3.Connection, delivery_id: str, job: Job) -> bool:
try:
with connection:
connection.execute(
"INSERT INTO deliveries VALUES (?, ?, ?)",
(delivery_id, job.comment_id, now()),
)
connection.execute(
"""
INSERT INTO jobs (
id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, workflow_id,
status, stage, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job.id,
job.kind,
job.target_key,
job.repo_owner,
job.repo_name,
job.issue_number,
job.pr_number,
job.requester,
job.message,
job.comment_id,
job.workflow_id,
job.status,
job.stage,
now(),
),
)
except sqlite3.IntegrityError:
return False
return True
async def claim_next(self) -> Job | None:
return await self._run(self._claim_next)
@staticmethod
def _claim_next(connection: sqlite3.Connection) -> Job | None:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT * FROM jobs WHERE status = ? ORDER BY created_at LIMIT 1",
(JobStatus.QUEUED,),
).fetchone()
if row is None:
connection.commit()
return None
connection.execute(
"UPDATE jobs SET status = ?, stage = ?, started_at = ? WHERE id = ?",
(JobStatus.RUNNING, "starting", now(), row["id"]),
)
connection.commit()
return job_from_row(row, status=JobStatus.RUNNING, stage="starting")
async def update_job(
self,
job_id: str,
*,
status: JobStatus | None = None,
stage: str | None = None,
error: str | None = None,
workflow_id: str | None = None,
) -> None:
updates: dict[str, object] = {}
if status is not None:
updates["status"] = status
if status in {JobStatus.SUCCEEDED, JobStatus.FAILED, JobStatus.REJECTED}:
updates["finished_at"] = now()
if stage is not None:
updates["stage"] = stage
if error is not None:
updates["error"] = error
if workflow_id is not None:
updates["workflow_id"] = workflow_id
await self._update("jobs", job_id, updates)
log.info(
"job state updated",
extra={
"operation": "job.update",
"job_id": job_id,
"workflow_id": workflow_id,
"stage": stage,
"status_code": status.value if status is not None else None,
},
)
async def set_job_comment(self, job_id: str, column: str, comment_id: int) -> None:
if column not in {"accepted_comment_id", "started_comment_id"}:
raise ValueError("Unsupported comment column")
await self._run(
lambda connection: connection.execute(
f"UPDATE jobs SET {column} = ? WHERE id = ?", (comment_id, job_id)
)
)
async def job_stage(self, job_id: str) -> str:
def select(connection: sqlite3.Connection) -> str:
row = connection.execute("SELECT stage FROM jobs WHERE id=?", (job_id,)).fetchone()
return str(row["stage"]) if row else "unknown"
return await self._run(select)
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 recover_running(self) -> list[Job]:
def recover(connection: sqlite3.Connection) -> list[Job]:
rows = connection.execute(
"SELECT * FROM jobs WHERE status=?", (JobStatus.RUNNING,)
).fetchall()
with connection:
connection.execute(
"""
UPDATE jobs SET status=?, stage=?, error=?, finished_at=?
WHERE status=?
""",
(
JobStatus.FAILED,
"interrupted",
"Service restarted during an active Codex turn",
now(),
JobStatus.RUNNING,
),
)
return [job_from_row(row) for row in rows]
return await self._run(recover)
def job_from_row(
row: sqlite3.Row,
*,
status: JobStatus | None = None,
stage: str | None = None,
) -> Job:
return Job(
id=row["id"],
kind=JobKind(row["kind"]),
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"],
workflow_id=row["workflow_id"],
status=status or JobStatus(row["status"]),
stage=stage or row["stage"],
accepted_comment_id=row["accepted_comment_id"],
)
-7
View File
@@ -1,7 +0,0 @@
from agentci.adapters.job_store import JobStore
from agentci.adapters.workflow_store import WorkflowStore
class Storage(JobStore, WorkflowStore):
"""Combined durable job and workflow repository."""
-145
View File
@@ -1,145 +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 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,
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,
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"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
reviewer_session_id=row["reviewer_session_id"],
artifact=row["artifact"],
review_json=row["review_json"],
status=WorkflowStatus(row["status"]),
)
+1 -2
View File
@@ -1,2 +1 @@
"""HTTP API.""" """HTTP application and routes."""
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from fastapi import FastAPI
from agentci.api.errors import register_error_handlers
from agentci.api.lifespan import create_lifespan
from agentci.api.routes.health import router as health_router
from agentci.api.routes.webhook import router as webhook_router
from agentci.config.settings import Settings
def create_app(settings: Settings | None = None) -> FastAPI:
selected_settings = settings or Settings()
app = FastAPI(
title="Agent CI",
version="0.1.0",
lifespan=create_lifespan(selected_settings),
)
app.include_router(health_router)
app.include_router(webhook_router)
register_error_handlers(app)
return app
app = create_app()
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Request
from agentci.application.runtime import Runtime
def get_runtime(request: Request) -> Runtime:
return request.app.state.runtime
type RuntimeDependency = Annotated[Runtime, Depends(get_runtime)]
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
log = logging.getLogger(__name__)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
log.exception(
"unhandled HTTP request failure",
extra={
"operation": "http.request",
"method": request.method,
"path": request.url.path,
"error_message": str(exc),
"status_code": 500,
},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error. See service logs for diagnostics."},
)
def register_error_handlers(app: FastAPI) -> None:
app.exception_handler(Exception)(unhandled_error)
-19
View File
@@ -1,19 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Request, Response, status
router = APIRouter()
@router.get("/health/live")
async def live() -> dict[str, str]:
return {"status": "live"}
@router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]:
if not await request.app.state.container.codex.login_ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "codex is not authenticated"}
return {"status": "ready"}
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
from fastapi import FastAPI
from agentci.application.runtime import build_runtime
from agentci.config.settings import Settings
from agentci.observability.logging import configure_logging
log = logging.getLogger(__name__)
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
def create_lifespan(settings: Settings) -> Lifespan:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
configure_logging()
log.info("service startup started", extra={"operation": "service.startup"})
try:
runtime = await build_runtime(settings)
except Exception:
log.exception("service startup failed", extra={"operation": "service.startup"})
raise
app.state.runtime = runtime
stop = asyncio.Event()
worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
try:
log.info("service startup completed", extra={"operation": "service.startup"})
yield
finally:
log.info("service shutdown started", extra={"operation": "service.shutdown"})
stop.set()
worker_task.cancel()
try:
with suppress(asyncio.CancelledError):
await worker_task
finally:
await runtime.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
return lifespan
+1
View File
@@ -0,0 +1 @@
"""FastAPI route modules."""
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from fastapi import APIRouter, Response, status
from agentci.api.dependencies import RuntimeDependency
router = APIRouter()
@router.get("/health/live")
async def live() -> dict[str, str]:
return {"status": "live"}
@router.get("/health/ready")
async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]:
if not await runtime.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "opencode provider is not connected"}
return {"status": "ready"}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request, Response, status
from agentci.api.dependencies import RuntimeDependency
from agentci.application.runtime import Runtime
from agentci.engine.model import IncomingCommand
from agentci.integrations.gitea.webhooks import (
SUPPORTED_EVENTS,
incoming_command_from_payload,
valid_signature,
)
router = APIRouter()
log = logging.getLogger(__name__)
@router.post("/webhooks/gitea")
async def webhook(request: Request, runtime: RuntimeDependency) -> Response:
body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "")
if not valid_signature(runtime.settings.webhook_secret, body, signature):
log.warning(
"webhook signature rejected",
extra={"operation": "webhook.verify", "path": request.url.path},
)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid webhook signature")
event_name = request.headers.get("X-Gitea-Event-Type") or request.headers.get(
"X-Gitea-Event", ""
)
if event_name not in SUPPORTED_EVENTS:
log.info(
"unsupported webhook ignored",
extra={"operation": "webhook.filter", "stage": event_name or "missing"},
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
payload = json.loads(body)
if not isinstance(payload, dict):
raise ValueError("Webhook payload must be a JSON object")
event = incoming_command_from_payload(
request.headers.get("X-Gitea-Delivery", ""),
payload,
)
except (KeyError, TypeError, ValueError) as exc:
log.warning(
"webhook payload rejected",
extra={"operation": "webhook.parse", "stage": event_name},
exc_info=exc,
)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
if event is None or event.requester.casefold() == runtime.settings.bot_username.casefold():
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
return Response(status_code=status.HTTP_204_NO_CONTENT)
return await _handle_command(runtime, event)
async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
extra = {"operation": "command.handle", "target": event.target_key}
if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT)
if not event.delivery_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
log.info("agent command received", extra=extra)
try:
result = await runtime.repository.accept(event)
except Exception:
log.exception("could not persist command", extra=extra)
raise
if result.duplicate:
log.info("duplicate command ignored", extra={**extra, "job_id": result.job.id})
return Response(status_code=status.HTTP_200_OK)
log.info(
"agent command persisted",
extra={
**extra,
"job_id": result.job.id,
"receive_sequence": result.job.receive_sequence,
},
)
return Response(status_code=status.HTTP_202_ACCEPTED)
-147
View File
@@ -1,147 +0,0 @@
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, Response, status
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.models import CommandEvent, Job, JobStatus
router = APIRouter()
log = logging.getLogger(__name__)
SUPPORTED_EVENTS = {
"issue_comment",
"pull_request_comment",
"pull_request_review_comment",
}
@router.post("/webhooks/gitea")
async def webhook(request: Request) -> Response:
container = request.app.state.container
body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "")
if not valid_signature(container.settings.webhook_secret, body, signature):
log.warning(
"webhook signature rejected",
extra={"operation": "webhook.verify", "path": request.url.path},
)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid webhook signature")
event_name = request.headers.get("X-Gitea-Event-Type") or request.headers.get(
"X-Gitea-Event", ""
)
if event_name not in SUPPORTED_EVENTS:
log.info(
"unsupported webhook ignored",
extra={"operation": "webhook.filter", "stage": event_name or "missing"},
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
payload = json.loads(body)
event = _event_from_payload(
request.headers.get("X-Gitea-Delivery", ""),
payload,
)
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
log.warning(
"webhook payload rejected",
extra={"operation": "webhook.parse", "stage": event_name},
exc_info=exc,
)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
if event is None or event.requester == container.settings.bot_username:
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
return Response(status_code=status.HTTP_204_NO_CONTENT)
return await _handle_command(container, event)
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
return bool(signature) and hmac.compare_digest(expected, signature)
async def _handle_command(container: Any, event: CommandEvent) -> Response:
extra = {"operation": "command.handle", "target": event.target_key}
if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT)
log.info("agent command received", extra=extra)
try:
command = parse_command(event.body)
except CommandError as exc:
log.info("agent command rejected: invalid syntax", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment(
event.repo_owner, event.repo_name, event.issue_number, str(exc)
)
return Response(status_code=status.HTTP_202_ACCEPTED)
if command is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
kind = resolve_job_kind(command, is_pull_request=event.is_pull_request)
except CommandError as exc:
log.info("agent command rejected: invalid target", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment(
event.repo_owner, event.repo_name, event.issue_number, str(exc)
)
return Response(status_code=status.HTTP_202_ACCEPTED)
job = Job(
id=str(uuid4()),
kind=kind,
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,
message=command.message,
comment_id=event.comment_id,
status=JobStatus.QUEUED,
stage="queued",
)
if not await container.storage.enqueue(event.delivery_id, job):
log.info("duplicate command ignored", extra={**extra, "job_id": job.id})
return Response(status_code=status.HTTP_200_OK)
log.info(
"agent job queued",
extra={**extra, "job_id": job.id, "stage": job.kind.value},
)
comment_id = await container.gitea.create_comment(
event.repo_owner,
event.repo_name,
event.issue_number,
f"Agent job `{job.id}` queued (`{job.kind}`).",
)
await container.storage.set_job_comment(job.id, "accepted_comment_id", comment_id)
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 "",
)
-69
View File
@@ -1,69 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
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()
try:
await worker_task
finally:
await container.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
app.include_router(health_router)
app.include_router(webhook_router)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
log.exception(
"unhandled HTTP request failure",
extra={
"operation": "http.request",
"method": request.method,
"path": request.url.path,
"status_code": 500,
},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error. See service logs for diagnostics."},
)
return app
app = create_app()
+1
View File
@@ -0,0 +1 @@
"""Application composition and durable orchestration."""
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from agentci.application.worker.runner import Worker
from agentci.config.settings import Settings
from agentci.engine.repository import Repository
from agentci.integrations.development import DevelopmentEnvironment
from agentci.integrations.git import Git
from agentci.integrations.gitea.client import Gitea
from agentci.integrations.opencode.client import OpenCode
from agentci.prompts.library import PromptLibrary
from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class Runtime:
settings: Settings
repository: Repository
gitea: Gitea
git: Git
opencode: OpenCode
worker: Worker
async def close(self) -> None:
log.info("runtime shutdown started", extra={"operation": "runtime.close"})
try:
await self.opencode.close()
finally:
await self.gitea.close()
log.info("runtime shutdown completed", extra={"operation": "runtime.close"})
async def build_runtime(settings: Settings) -> Runtime:
log.info("runtime initialization started", extra={"operation": "runtime.build"})
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
repository = Repository(settings.database_path)
await repository.initialize()
gitea = Gitea(settings.gitea_url, settings.gitea_token)
git = Git(
gitea_url=settings.gitea_url,
username=settings.bot_username,
token=settings.gitea_token,
askpass_path=settings.askpass_path,
commit_name=settings.bot_name,
commit_email=settings.bot_email,
)
prompts = PromptLibrary()
opencode = OpenCode(
base_url=settings.opencode_url,
username=settings.opencode_server_username,
password=settings.opencode_server_password,
schemas_dir=prompts.schemas_dir,
health_directory=settings.workspaces_dir,
required_models=(
(settings.plan_model, settings.plan_variant),
(settings.implement_model, settings.implement_variant),
(settings.explore_model, settings.explore_variant),
(settings.research_model, settings.research_variant),
),
timeout_seconds=settings.turn_timeout_seconds,
)
development = DevelopmentEnvironment(
scripts=settings.install_scripts,
scripts_dir=settings.install_scripts_dir,
tools_dir=settings.dev_tools_dir,
timeout_seconds=settings.install_script_timeout_seconds,
python_version=settings.python_version,
dotnet_channel=settings.dotnet_channel,
)
services = WorkflowServices(
settings=settings,
repository=repository,
gitea=gitea,
git=git,
opencode=opencode,
prompts=prompts,
development=development,
)
worker = Worker(
repository=repository,
gitea=gitea,
opencode=opencode,
services=services,
poll_seconds=settings.worker_poll_seconds,
max_concurrent_jobs=settings.max_concurrent_jobs,
workspaces_dir=settings.workspaces_dir,
bot_username=settings.bot_username,
)
runtime = Runtime(settings, repository, gitea, git, opencode, worker)
log.info("runtime initialization completed", extra={"operation": "runtime.build"})
return runtime
@@ -0,0 +1 @@
"""Durable task runner and task handlers."""
@@ -0,0 +1,17 @@
from __future__ import annotations
from agentci.engine.events import PermissionDenied, PermissionGranted
from agentci.engine.model import Job, JobStatus, Task
from agentci.engine.repository import Repository
from agentci.integrations.gitea.client import Gitea
async def authorize_job(
*, task: Task, job: Job, repository: Repository, gitea: Gitea
) -> None:
if job.status is not JobStatus.RECEIVED:
return
permitted = await gitea.has_write_permission(job.repo_owner, job.repo_name, job.requester)
event = PermissionGranted(job_id=job.id) if permitted else PermissionDenied(job_id=job.id)
outcome = "permission-granted" if permitted else "permission-denied"
await repository.apply(f"task:{task.id}:{outcome}", event)
@@ -0,0 +1,48 @@
from __future__ import annotations
from agentci.engine.events import CommentLinked
from agentci.engine.model import Job, Task
from agentci.engine.reducer import render_job_comment
from agentci.engine.repository import Repository
from agentci.integrations.gitea.client import Gitea
async def reconcile_comment(
*,
task: Task,
job: Job,
repository: Repository,
gitea: Gitea,
bot_username: str,
) -> None:
latest = await repository.get_job(job.id)
if latest is None:
return
body = render_job_comment(latest)
comment_id = latest.accepted_comment_id
if comment_id is not None and await gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
):
return
marker = f"<!-- agentci:job id={latest.id} -->"
matches = sorted(
comment.id
for comment in await gitea.issue_comments(
latest.repo_owner, latest.repo_name, latest.issue_number
)
if comment.body.startswith(marker)
and comment.author.casefold() == bot_username.casefold()
)
if matches:
comment_id = matches[0]
else:
comment_id = await gitea.create_comment(
latest.repo_owner, latest.repo_name, latest.issue_number, body
)
await repository.apply(
f"task:{task.id}:comment:{comment_id}",
CommentLinked(job_id=latest.id, comment_id=comment_id),
)
await gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
)
+6
View File
@@ -0,0 +1,6 @@
from __future__ import annotations
def safe_error(error: Exception) -> str:
message = " ".join(str(error).split())
return f"{type(error).__name__}: {message}"[:1000]
@@ -0,0 +1,47 @@
from __future__ import annotations
from agentci.application.worker.errors import safe_error
from agentci.engine.events import JobCompleted, JobFailed, JobStarted, ServiceRestarted
from agentci.engine.events import JobRejected as RejectedEvent
from agentci.engine.model import Job, JobStatus, Task
from agentci.engine.repository import Repository
from agentci.engine.run import JobRun
from agentci.workflows.dispatch import dispatch
from agentci.workflows.render import JobRejected
from agentci.workflows.services import WorkflowServices
async def execute_job(
*, task: Task, job: Job, repository: Repository, services: WorkflowServices
) -> None:
if job.status is JobStatus.RUNNING:
await repository.apply(
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
)
return
if job.status is not JobStatus.QUEUED:
return
result = await repository.apply(f"task:{task.id}:started", JobStarted(job_id=job.id))
running = result.job
run = JobRun(repository, job.id, task.id)
try:
body = await dispatch(running, run, services)
except JobRejected as exc:
await repository.apply(
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
)
except Exception as exc:
latest = await repository.get_job(job.id)
stage = latest.stage if latest else running.stage
await repository.apply(
f"task:{task.id}:failed",
JobFailed(job_id=job.id, error=safe_error(exc), stage=stage),
)
else:
await repository.apply(
f"task:{task.id}:completed",
JobCompleted(
job_id=job.id,
comment_body=body or "Agent job completed.",
),
)
@@ -0,0 +1,13 @@
from __future__ import annotations
from agentci.engine.events import ServiceRestarted
from agentci.engine.repository import Repository
async def recover_jobs(repository: Repository) -> None:
await repository.recover_tasks()
for job in await repository.running_jobs():
await repository.apply(
f"recovery:{job.id}:service-restarted",
ServiceRestarted(job_id=job.id),
)
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from pathlib import Path
from agentci.application.worker.authorization import authorize_job
from agentci.application.worker.comments import reconcile_comment
from agentci.application.worker.errors import safe_error
from agentci.application.worker.execution import execute_job
from agentci.application.worker.recovery import recover_jobs
from agentci.application.worker.sessions import abort_job_sessions
from agentci.engine.model import Job, QueueName, Task, TaskKind
from agentci.engine.repository import Repository
from agentci.integrations.gitea.client import Gitea
from agentci.integrations.opencode.client import OpenCode
from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__)
class Worker:
def __init__(
self,
*,
repository: Repository,
gitea: Gitea,
opencode: OpenCode,
services: WorkflowServices,
poll_seconds: float,
max_concurrent_jobs: int,
workspaces_dir: Path,
bot_username: str,
) -> None:
self.repository = repository
self.gitea = gitea
self.opencode = opencode
self.services = services
self.poll_seconds = poll_seconds
self.max_concurrent_jobs = max_concurrent_jobs
self.workspaces_dir = workspaces_dir
self.bot_username = bot_username
async def run(self, stop: asyncio.Event) -> None:
await self._recover()
await asyncio.gather(
self._loop(QueueName.CONTROL, stop),
*(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
)
async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
while not stop.is_set():
if queue is QueueName.JOBS and not await self.opencode.ready():
await self._wait(stop)
continue
task = await self.repository.claim_task(queue)
if task is None:
await self._wait(stop)
continue
try:
await self._handle(task)
except asyncio.CancelledError:
raise
except Exception as exc:
log.exception(
"listener failed",
extra={
"task_id": task.id,
"listener": task.kind.value,
"queue": queue.value,
},
)
await self.repository.retry_task(task.id, task.attempts, safe_error(exc))
else:
await self.repository.complete_task(task.id)
async def _handle(self, task: Task) -> None:
job = await self.repository.get_job(task.job_id)
if job is None:
return
match task.kind:
case TaskKind.AUTHORIZE:
await self._authorize(task, job)
case TaskKind.EXECUTE:
await self._execute(task, job)
case TaskKind.RECONCILE_COMMENT:
await self._reconcile(task, job)
case TaskKind.FAIL_WORKFLOW:
await self.repository.fail_job_workflow(job.id)
case TaskKind.ABORT_SESSIONS:
await self._abort_job_sessions(job)
case _:
raise RuntimeError(f"Unknown task kind {task.kind}")
async def _authorize(self, task: Task, job: Job) -> None:
await authorize_job(
task=task,
job=job,
repository=self.repository,
gitea=self.gitea,
)
async def _execute(self, task: Task, job: Job) -> None:
await execute_job(
task=task,
job=job,
repository=self.repository,
services=self.services,
)
async def _reconcile(self, task: Task, job: Job) -> None:
await reconcile_comment(
task=task,
job=job,
repository=self.repository,
gitea=self.gitea,
bot_username=self.bot_username,
)
async def _recover(self) -> None:
await recover_jobs(self.repository)
async def _abort_job_sessions(self, job: Job) -> None:
await abort_job_sessions(
job=job,
repository=self.repository,
opencode=self.opencode,
workspaces_dir=self.workspaces_dir,
)
async def _wait(self, stop: asyncio.Event) -> None:
with suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
@@ -0,0 +1,30 @@
from __future__ import annotations
from pathlib import Path
from agentci.engine.model import Job
from agentci.engine.repository import Repository
from agentci.integrations.opencode.client import OpenCode
async def abort_job_sessions(
*,
job: Job,
repository: Repository,
opencode: OpenCode,
workspaces_dir: Path,
) -> None:
sessions: set[tuple[str, Path]] = set()
workflow = await repository.get_workflow(job.workflow_id) if job.workflow_id else None
if workflow:
sessions.update(
(session, workflow.workspace_path)
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
if session
)
elif job.runtime_session_id:
sessions.add(
(job.runtime_session_id, workspaces_dir / f"fix-{job.id}" / "repo")
)
for session, workspace in sessions:
await opencode.abort(session, workspace)
+1
View File
@@ -0,0 +1 @@
"""Service configuration."""
@@ -5,7 +5,7 @@ from functools import cached_property
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from pydantic import Field, SecretStr, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@@ -21,38 +21,49 @@ class Settings(BaseSettings):
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8080 port: int = 8080
data_dir: Path = Path("/var/lib/agentci") data_dir: Path = Path("/var/lib/agentci")
codex_home: Path = Path("/var/lib/codex")
gitea_url: str = "http://gitea:3000" gitea_url: str = "http://gitea:3000"
gitea_token_file: Path = Path("/run/secrets/gitea_token") gitea_token_file: Path = Path("/run/secrets/gitea_token")
webhook_secret_file: Path = Path("/run/secrets/webhook_secret") webhook_secret_file: Path = Path("/run/secrets/webhook_secret")
opencode_url: str = "http://opencode:4096"
opencode_server_username: str = "opencode"
opencode_server_password_file: Path = Path("/run/secrets/opencode_server_password")
bot_username: str = "agentci" bot_username: str = "agentci"
bot_name: str = "Agent CI" bot_name: str = "Agent CI"
bot_email: str = "agentci@localhost" bot_email: str = "agentci@localhost"
branch_prefix: str = "agent" branch_prefix: str = "agent"
askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh") askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh")
plan_model: str = "gpt-5.6-sol" plan_model: str = "openai/gpt-5.6-sol"
plan_reasoning: str = "medium" plan_variant: str | None = None
implement_model: str = "gpt-5.6-sol" implement_model: str = "openai/gpt-5.6-sol"
implement_reasoning: str = "high" implement_variant: str | None = None
research_model: str = "gpt-5.6-luna" explore_model: str = "openai/gpt-5.6-luna"
research_reasoning: str = "high" explore_variant: str = "low"
context7_api_key: SecretStr | None = None research_model: str = "openai/gpt-5.6-luna"
research_variant: str = "high"
plan_review_rounds: int = Field(default=4, ge=1, le=20) plan_review_rounds: int = Field(default=4, ge=1, le=20)
implement_review_rounds: int = Field(default=3, ge=1, le=20) implement_review_rounds: int = Field(default=3, ge=1, le=20)
turn_timeout_seconds: int = Field(default=3600, ge=60) turn_timeout_seconds: int = Field(default=3600, ge=60)
install_script_timeout_seconds: int = Field(default=900, ge=1) install_script_timeout_seconds: int = Field(default=900, ge=1)
worker_poll_seconds: float = Field(default=1.0, ge=0.1) worker_poll_seconds: float = Field(default=1.0, ge=0.1)
public_agent_network: bool = True max_concurrent_jobs: int = Field(default=2, ge=1, le=32)
install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list) install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list)
install_scripts_dir: Path = Path("/etc/agentci/install-scripts") install_scripts_dir: Path = Path("/etc/agentci/install-scripts")
python_version: str = "3.13" python_version: str = "3.13"
dotnet_channel: str = "10.0" dotnet_channel: str = "10.0"
@field_validator("gitea_url") @field_validator("gitea_url", "opencode_url")
@classmethod @classmethod
def strip_url(cls, value: str) -> str: def strip_url(cls, value: str) -> str:
return value.rstrip("/") return value.rstrip("/")
@field_validator("plan_model", "implement_model", "explore_model", "research_model")
@classmethod
def validate_opencode_model(cls, value: str) -> str:
provider, separator, model = value.partition("/")
if not separator or not provider or not model:
raise ValueError("OpenCode models must use provider/model format")
return value
@field_validator("install_scripts", mode="before") @field_validator("install_scripts", mode="before")
@classmethod @classmethod
def parse_install_scripts(cls, value: object) -> list[str]: def parse_install_scripts(cls, value: object) -> list[str]:
@@ -77,6 +88,12 @@ class Settings(BaseSettings):
def webhook_secret(self) -> bytes: def webhook_secret(self) -> bytes:
return self._read_secret(self.webhook_secret_file, "webhook secret").encode() return self._read_secret(self.webhook_secret_file, "webhook secret").encode()
@cached_property
def opencode_server_password(self) -> str:
return self._read_secret(
self.opencode_server_password_file, "OpenCode server password"
)
@property @property
def database_path(self) -> Path: def database_path(self) -> Path:
return self.data_dir / "agentci.sqlite3" return self.data_dir / "agentci.sqlite3"
-96
View File
@@ -1,96 +0,0 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from agentci.adapters.codex import CodexClient
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.prompts import PromptLibrary
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
codex: CodexClient
worker: Worker
async def close(self) -> None:
log.info("container shutdown started", extra={"operation": "container.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)
settings.codex_home.mkdir(parents=True, exist_ok=True)
storage = Storage(settings.database_path, package_dir / "migrations")
await storage.initialize()
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,
)
codex = CodexClient(
codex_home=settings.codex_home,
schemas_dir=package_dir / "prompts" / "schemas",
timeout_seconds=settings.turn_timeout_seconds,
research_model=settings.research_model,
research_reasoning=settings.research_reasoning,
context7_api_key=(
settings.context7_api_key.get_secret_value()
if settings.context7_api_key is not None
else None
),
tools_bin=settings.dev_tools_dir / "bin",
)
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,
codex=codex,
prompts=prompts,
context=context,
development=development,
)
dispatcher = Dispatcher(dependencies)
worker = Worker(
storage=storage,
gitea=gitea,
codex=codex,
dispatcher=dispatcher,
poll_seconds=settings.worker_poll_seconds,
)
container = Container(settings, storage, gitea, git, codex, worker)
log.info("container initialization completed", extra={"operation": "container.build"})
return container
-2
View File
@@ -1,2 +0,0 @@
"""Domain types and policies."""
+1
View File
@@ -0,0 +1 @@
"""Durable job and workflow engine."""
+271
View File
@@ -0,0 +1,271 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from agentci.engine.events import JobEvent
from agentci.engine.model import (
Job,
JobKind,
JobStatus,
QueueName,
Task,
TaskKind,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import Transition
def now() -> str:
return datetime.now(UTC).isoformat()
def connect(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path, timeout=30)
try:
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
except Exception:
connection.close()
raise
return connection
def initialize(connection: sqlite3.Connection, migrations_dir: Path) -> None:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {row[0] for row in connection.execute("SELECT version FROM schema_migrations")}
for path in sorted(migrations_dir.glob("*.sql")):
version = int(path.name.split("_", 1)[0])
if version in applied:
continue
connection.executescript(path.read_text())
connection.execute(
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
(version, now()),
)
def insert_event(
connection: sqlite3.Connection,
event_id: str,
event: JobEvent,
timestamp: str,
) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), timestamp),
)
def insert_tasks(
connection: sqlite3.Connection,
event_id: str,
transition: Transition,
timestamp: str,
) -> None:
for ordinal, task in enumerate(transition.tasks):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.job.id,
event_id,
ordinal,
task.kind.value,
task.queue.value,
timestamp,
timestamp,
),
)
def insert_job(connection: sqlite3.Connection, job: Job, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_job_values(job), timestamp),
)
def replace_job(
connection: sqlite3.Connection,
job: Job,
previous: Job,
timestamp: str,
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and job.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and job.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_job_values(job)[1:], started, finished, job.id),
)
def optional_job(connection: sqlite3.Connection, job_id: str) -> Job | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return job_from_row(row) if row else None
def required_job(connection: sqlite3.Connection, job_id: str) -> Job:
job = optional_job(connection, job_id)
if job is None:
raise KeyError(f"Unknown job {job_id}")
return job
def job_from_row(row: sqlite3.Row) -> Job:
return Job(
id=row["id"],
kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"],
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
requester=row["requester"],
message=row["message"],
comment_id=row["comment_id"],
delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"],
command_body=row["command_body"],
workflow_id=row["workflow_id"],
status=JobStatus(row["status"]),
stage=row["stage"],
error=row["error"],
runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"],
comment_body=row["comment_body"],
)
def insert_workflow(
connection: sqlite3.Connection,
workflow: Workflow,
timestamp: str,
) -> None:
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id,
workflow.kind.value,
workflow.repo_owner,
workflow.repo_name,
workflow.issue_number,
workflow.pr_number,
workflow.base_sha,
workflow.branch,
str(workflow.workspace_path),
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
workflow.runtime,
timestamp,
timestamp,
),
)
def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
if row is None:
return None
return Workflow(
id=row["id"],
kind=WorkflowKind(row["kind"]),
repo_owner=row["repo_owner"],
repo_name=row["repo_name"],
issue_number=row["issue_number"],
pr_number=row["pr_number"],
base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"],
workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"],
reviewer_session_id=row["reviewer_session_id"],
artifact=row["artifact"],
review_json=row["review_json"],
status=WorkflowStatus(row["status"]),
)
def claim_task(connection: sqlite3.Connection, queue: str) -> Task | None:
connection.execute("BEGIN IMMEDIATE")
fifo = ""
if queue == QueueName.JOBS.value:
fifo = """AND NOT EXISTS (
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
AND earlier.target_key = j.target_key
AND earlier.status IN ('received', 'queued', 'running'))"""
row = connection.execute(
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
ORDER BY {"j.receive_sequence" if queue == QueueName.JOBS.value else "t.id"} LIMIT 1""",
(queue, now()),
).fetchone()
if row is None:
connection.commit()
return None
changed = connection.execute(
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
"WHERE id=? AND status='pending'",
(now(), row["id"]),
)
if changed.rowcount != 1:
connection.rollback()
return None
connection.commit()
return Task(
id=row["id"],
job_id=row["job_id"],
source_event_id=row["source_event_id"],
kind=TaskKind(row["listener"]),
queue=QueueName(row["queue"]),
attempts=row["attempts"] + 1,
)
def _job_values(job: Job) -> tuple[object, ...]:
return (
job.id,
job.kind.value if job.kind else None,
job.target_key,
job.repo_owner,
job.repo_name,
job.issue_number,
job.pr_number,
job.requester,
job.message,
job.comment_id,
job.delivery_id,
job.receive_sequence,
job.command_body,
job.workflow_id,
job.status.value,
job.stage,
job.error,
job.runtime_session_id,
job.accepted_comment_id,
job.comment_body,
)
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from agentci.domain.models import CommandName, JobKind, ParsedCommand from agentci.engine.model import CommandName, JobKind, ParsedCommand
COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$") COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$")
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
from agentci.engine.model import Workflow
class Event(BaseModel):
model_config = ConfigDict(frozen=True)
job_id: str
class CommandReceived(Event):
type: Literal["command_received"] = "command_received"
delivery_id: str
receive_sequence: int
command_body: str
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
comment_id: int
class PermissionGranted(Event):
type: Literal["permission_granted"] = "permission_granted"
class PermissionDenied(Event):
type: Literal["permission_denied"] = "permission_denied"
class JobStarted(Event):
type: Literal["job_started"] = "job_started"
class JobProgress(Event):
type: Literal["job_progress"] = "job_progress"
stage: str
class WorkflowCreated(Event):
type: Literal["workflow_created"] = "workflow_created"
workflow: Workflow
stage: str
class WorkflowLinked(Event):
type: Literal["workflow_linked"] = "workflow_linked"
workflow_id: str
stage: str
class RuntimeSessionLinked(Event):
type: Literal["runtime_session_linked"] = "runtime_session_linked"
session_id: str
class JobCompleted(Event):
type: Literal["job_completed"] = "job_completed"
comment_body: str
class JobRejected(Event):
type: Literal["job_rejected"] = "job_rejected"
reason: str
class JobFailed(Event):
type: Literal["job_failed"] = "job_failed"
error: str
stage: str
class ServiceRestarted(Event):
type: Literal["service_restarted"] = "service_restarted"
class CommentLinked(Event):
type: Literal["comment_linked"] = "comment_linked"
comment_id: int
JobEvent = Annotated[
CommandReceived
| PermissionGranted
| PermissionDenied
| JobStarted
| JobProgress
| WorkflowCreated
| WorkflowLinked
| RuntimeSessionLinked
| JobCompleted
| JobRejected
| JobFailed
| ServiceRestarted
| CommentLinked,
Field(discriminator="type"),
]
@@ -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"
@@ -25,6 +23,7 @@ class JobKind(StrEnum):
class JobStatus(StrEnum): class JobStatus(StrEnum):
RECEIVED = "received"
QUEUED = "queued" QUEUED = "queued"
RUNNING = "running" RUNNING = "running"
SUCCEEDED = "succeeded" SUCCEEDED = "succeeded"
@@ -43,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)
@@ -89,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
@@ -109,25 +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 runtime_session_id: str | None = None
stage: str = "queued"
accepted_comment_id: int | 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
@@ -136,6 +119,7 @@ class Workflow:
issue_number: int issue_number: int
workspace_path: Path workspace_path: Path
base_sha: str base_sha: str
runtime: str = "opencode"
branch: str | None = None branch: str | None = None
pr_number: int | None = None pr_number: int | None = None
primary_session_id: str | None = None primary_session_id: str | None = None
@@ -143,3 +127,19 @@ class Workflow:
artifact: str | None = None artifact: str | None = None
review_json: str | None = None review_json: str | None = None
status: WorkflowStatus = WorkflowStatus.ACTIVE status: WorkflowStatus = WorkflowStatus.ACTIVE
@dataclass(frozen=True)
class TaskRequest:
kind: TaskKind
queue: QueueName
@dataclass(frozen=True)
class Task:
id: int
job_id: str
source_event_id: str
kind: TaskKind
queue: QueueName
attempts: int
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
from agentci.engine.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Job, JobStatus, QueueName, TaskKind, TaskRequest
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Transition:
job: Job
tasks: tuple[TaskRequest, ...] = ()
RECONCILE = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
def reduce_job(current: Job | None, event: JobEvent) -> Transition:
if current is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
job = Job(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
task = TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL)
return Transition(job, (task,))
if event.job_id != current.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(current, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if current.status is not JobStatus.RUNNING:
return Transition(current)
failed = replace(
current,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
tasks = [TaskRequest(TaskKind.ABORT_SESSIONS, QueueName.CONTROL), RECONCILE]
if current.workflow_id:
tasks.insert(1, TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
return Transition(failed, tuple(tasks))
if current.status is JobStatus.RECEIVED:
return _received(current, event)
if current.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(current, status=JobStatus.RUNNING, stage="starting"),
(RECONCILE,),
)
if current.status is JobStatus.RUNNING:
return _running(current, event)
raise InvalidTransition(f"{event.type} is invalid while job is {current.status}")
def _received(current: Job, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(current, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(current.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=current.is_pull_request)
except CommandError as exc:
return Transition(
replace(current, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
current,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
tasks = (
TaskRequest(TaskKind.EXECUTE, QueueName.JOBS),
RECONCILE,
)
return Transition(queued, tasks)
def _running(current: Job, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(current, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(current, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(current, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(current, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
current,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
tasks = [RECONCILE]
if current.workflow_id:
tasks.append(TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
return Transition(
replace(current, status=status, stage=stage, error=error),
tuple(tasks),
)
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(job: Job) -> str:
marker = f"<!-- agentci:job id={job.id} -->"
if job.status is JobStatus.SUCCEEDED and job.comment_body:
body = job.comment_body
elif job.status is JobStatus.REJECTED:
body = f"Agent job `{job.id}` was rejected: {job.error}"
elif job.status is JobStatus.FAILED:
body = f"Agent job `{job.id}` failed during `{job.stage}`: {job.error}"
else:
kind = job.kind.value if job.kind else "command"
body = f"Agent job `{job.id}` {job.status.value} (`{kind}`; stage: `{job.stage}`)."
return f"{marker}\n{body}"
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
import logging
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TypeVar
from uuid import UUID, uuid5
from agentci.engine import _sqlite
from agentci.engine.events import CommandReceived, JobEvent, WorkflowCreated
from agentci.engine.model import (
IncomingCommand,
Job,
QueueName,
Task,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.engine.reducer import reduce_job
T = TypeVar("T")
log = logging.getLogger(__name__)
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ApplyResult:
job: Job
duplicate: bool
class Repository:
def __init__(self, database_path: Path, migrations_dir: Path | None = None) -> None:
self.database_path = database_path
self.migrations_dir = migrations_dir or Path(__file__).parent.parent / "migrations"
async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True)
try:
await self._run(lambda connection: _sqlite.initialize(connection, self.migrations_dir))
except Exception:
log.exception(
"database initialization failed",
extra={"operation": "database.initialize"},
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
async def accept(self, command: IncomingCommand) -> ApplyResult:
job_id = str(uuid5(JOB_NAMESPACE, command.delivery_id))
event_id = f"delivery:{command.delivery_id}"
def operation(connection: sqlite3.Connection) -> ApplyResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
job = _sqlite.required_job(connection, duplicate["job_id"])
connection.commit()
return ApplyResult(job, True)
sequence = connection.execute(
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
).fetchone()[0]
event = CommandReceived(
job_id=job_id,
delivery_id=command.delivery_id,
receive_sequence=sequence,
command_body=command.body,
target_key=command.target_key,
repo_owner=command.repo_owner,
repo_name=command.repo_name,
issue_number=command.issue_number,
pr_number=command.pr_number,
requester=command.requester,
comment_id=command.comment_id,
)
transition = reduce_job(None, event)
timestamp = _sqlite.now()
_sqlite.insert_event(connection, event_id, event, timestamp)
_sqlite.insert_job(connection, transition.job, timestamp)
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return ApplyResult(transition.job, False)
return await self._run(operation)
async def apply(self, event_id: str, event: JobEvent) -> ApplyResult:
def operation(connection: sqlite3.Connection) -> ApplyResult:
connection.execute("BEGIN IMMEDIATE")
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if duplicate:
existing_job_id = duplicate["job_id"]
if existing_job_id != event.job_id:
raise RuntimeError(
f"Event ID {event_id!r} belongs to job {existing_job_id!r}, "
f"not {event.job_id!r}"
)
job = _sqlite.required_job(connection, existing_job_id)
connection.commit()
return ApplyResult(job, True)
current = _sqlite.required_job(connection, event.job_id)
transition = reduce_job(current, event)
timestamp = _sqlite.now()
_sqlite.insert_event(connection, event_id, event, timestamp)
if isinstance(event, WorkflowCreated):
_sqlite.insert_workflow(connection, event.workflow, timestamp)
_sqlite.replace_job(connection, transition.job, current, timestamp)
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return ApplyResult(transition.job, False)
return await self._run(operation)
async def get_job(self, job_id: str) -> Job | None:
return await self._run(lambda connection: _sqlite.optional_job(connection, job_id))
async def claim_task(self, queue: QueueName | str) -> Task | None:
queue_name = queue.value if isinstance(queue, QueueName) else queue
return await self._run(lambda connection: _sqlite.claim_task(connection, queue_name))
async def complete_task(self, task_id: int) -> None:
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
(_sqlite.now(), task_id),
)
)
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
delay = min(2 ** min(attempts, 8), 300)
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
(available, error[:1000], task_id),
)
)
async def recover_tasks(self) -> None:
def operation(connection: sqlite3.Connection) -> None:
with connection:
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND queue='control'"""
)
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status='queued')"""
)
connection.execute(
"""UPDATE listener_tasks SET status='failed', finished_at=?,
error='Service restarted after execution began'
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status<>'queued')""",
(_sqlite.now(),),
)
await self._run(operation)
async def running_jobs(self) -> list[Job]:
return await self._run(
lambda connection: [
_sqlite.job_from_row(row)
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
]
)
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
return await self._run(
lambda connection: {
value
for row in connection.execute(
"SELECT accepted_comment_id, started_comment_id FROM jobs "
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
(owner, repo, issue),
)
for value in row
if value is not None
}
)
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute("SELECT * FROM workflows WHERE id=?", (workflow_id,)).fetchone()
)
)
async def save_workflow(self, workflow: Workflow) -> None:
def operation(connection: sqlite3.Connection) -> None:
updated = connection.execute(
"""UPDATE workflows SET pr_number=?, branch=?, primary_session_id=?,
reviewer_session_id=?, artifact=?, review_json=?, status=?, updated_at=?
WHERE id=?""",
(
workflow.pr_number,
workflow.branch,
workflow.primary_session_id,
workflow.reviewer_session_id,
workflow.artifact,
workflow.review_json,
workflow.status.value,
_sqlite.now(),
workflow.id,
),
)
if updated.rowcount != 1:
raise KeyError(f"Unknown workflow {workflow.id}")
await self._run(operation)
async def latest_workflow(
self,
owner: str,
repo: str,
issue: int,
kind: WorkflowKind,
) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=?
AND kind=? AND status=?
ORDER BY created_at DESC LIMIT 1""",
(owner, repo, issue, kind.value, WorkflowStatus.COMPLETED.value),
).fetchone()
)
)
async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None:
return await self._run(
lambda connection: _sqlite.workflow_from_row(
connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=?
ORDER BY created_at DESC LIMIT 1""",
(owner, repo, pr, WorkflowKind.IMPLEMENT.value),
).fetchone()
)
)
async def implementation_workflows(self, owner: str, repo: str, issue: int) -> list[Workflow]:
return await self._run(
lambda connection: [
workflow
for row in connection.execute(
"""SELECT * FROM workflows
WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=?
AND pr_number IS NOT NULL
ORDER BY created_at DESC""",
(owner, repo, issue, WorkflowKind.IMPLEMENT.value),
)
if (workflow := _sqlite.workflow_from_row(row)) is not None
]
)
async def fail_job_workflow(self, job_id: str) -> None:
await self._run(
lambda connection: connection.execute(
"""UPDATE workflows SET status=?, updated_at=?
WHERE id=(SELECT workflow_id FROM jobs WHERE id=?)
AND status=?""",
(
WorkflowStatus.FAILED.value,
_sqlite.now(),
job_id,
WorkflowStatus.ACTIVE.value,
),
)
)
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
connection = _sqlite.connect(self.database_path)
try:
with connection:
return operation(connection)
finally:
connection.close()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from agentci.engine.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import Workflow
from agentci.engine.repository import Repository
type ReportEvent = JobProgress | RuntimeSessionLinked | WorkflowCreated | WorkflowLinked
class JobRun:
def __init__(self, repository: Repository, job_id: str, task_id: int) -> None:
self.repository = repository
self.job_id = job_id
self.task_id = task_id
self._sequence = 0
async def stage(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
async def _emit(self, event: ReportEvent) -> None:
self._sequence += 1
event_id = f"task:{self.task_id}:report:{self._sequence}"
await self.repository.apply(event_id, event)
+1
View File
@@ -0,0 +1 @@
"""External service and process integrations."""
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
pass pass
class CodeGraphClient: class CodeGraph:
async def prepare(self, workspace: Path) -> None: async def prepare(self, workspace: Path) -> None:
self._exclude_index(workspace) self._exclude_index(workspace)
index = workspace / ".codegraph" / "codegraph.db" index = workspace / ".codegraph" / "codegraph.db"
@@ -32,6 +32,7 @@ class DevelopmentEnvironment:
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self.python_version = python_version self.python_version = python_version
self.dotnet_channel = dotnet_channel self.dotnet_channel = dotnet_channel
self._prepare_lock = asyncio.Lock()
@property @property
def description(self) -> str: def description(self) -> str:
@@ -40,6 +41,7 @@ class DevelopmentEnvironment:
async def prepare(self, workspace: Path) -> None: async def prepare(self, workspace: Path) -> None:
if not self.scripts: if not self.scripts:
return return
async with self._prepare_lock:
self.tools_dir.mkdir(parents=True, exist_ok=True) self.tools_dir.mkdir(parents=True, exist_ok=True)
(self.tools_dir / "bin").mkdir(exist_ok=True) (self.tools_dir / "bin").mkdir(exist_ok=True)
for name in self.scripts: for name in self.scripts:
@@ -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,27 +16,37 @@ 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:
await self.client.aclose() await self.client.aclose()
async def repository(self, owner: str, repo: str) -> RepositoryInfo: async def has_write_permission(self, owner: str, repo: str, username: str) -> bool:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json() response = await self._request(
return RepositoryInfo( "GET", f"/repos/{owner}/{repo}/collaborators/{username}/permission"
owner=owner,
name=repo,
full_name=data.get("full_name", f"{owner}/{repo}"),
default_branch=data["default_branch"],
) )
permission = str(response.json().get("permission", "")).lower()
return permission in {"write", "admin", "owner"}
async def default_branch(self, owner: str, repo: str) -> str:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return str(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()
@@ -98,6 +103,15 @@ class GiteaClient:
) )
return int(response.json()["id"]) return int(response.json()["id"])
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
response = await self._request(
"PATCH",
f"/repos/{owner}/{repo}/issues/comments/{comment_id}",
json={"body": body},
allow_not_found=True,
)
return response.status_code != 404
async def create_pull_request( async def create_pull_request(
self, self,
owner: str, owner: str,
@@ -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."""
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from pathlib import Path
from time import monotonic
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, ValidationError
from agentci.integrations.codegraph import CodeGraph
from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
from agentci.integrations.opencode.schemas import load_schema
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class OpenCodeError(RuntimeError):
pass
class OpenCode:
def __init__(
self,
*,
base_url: str,
username: str,
password: str,
schemas_dir: Path,
health_directory: Path,
required_models: tuple[tuple[str, str | None], ...],
timeout_seconds: int,
codegraph: CodeGraph | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.schemas_dir = schemas_dir
self.health_directory = health_directory
self.required_models = {
(*model_parts(model), variant) for model, variant in required_models
}
self._contract_valid: bool | None = None
self._readiness_task: asyncio.Task[bool] | None = None
self.timeout_seconds = timeout_seconds
self.codegraph = codegraph or CodeGraph()
self._active_sessions: dict[str, Path] = {}
self.client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
auth=httpx.BasicAuth(username, password),
timeout=httpx.Timeout(timeout_seconds, connect=10),
transport=transport,
)
async def close(self) -> None:
if self._readiness_task is not None and not self._readiness_task.done():
self._readiness_task.cancel()
with suppress(asyncio.CancelledError):
await self._readiness_task
self._readiness_task = None
for session_id, workspace in tuple(self._active_sessions.items()):
await self.abort(session_id, workspace, best_effort=True)
await self.client.aclose()
async def ready(self) -> bool:
task = self._readiness_task
if task is None:
task = asyncio.create_task(self._check_ready())
self._readiness_task = task
try:
return await asyncio.shield(task)
finally:
if task.done() and self._readiness_task is task:
self._readiness_task = None
async def _check_ready(self) -> bool:
try:
health = await self.client.get("/global/health", timeout=10)
health.raise_for_status()
if health.json().get("healthy") is not True:
return False
if not str(health.json().get("version", "")).startswith("1."):
return False
if self._contract_valid is None:
document = await self.client.get("/doc", timeout=10)
document.raise_for_status()
self._contract_valid = api_contract_ready(document.json())
if not self._contract_valid:
return False
providers = await self.client.get(
"/provider", headers=directory_headers(self.health_directory), timeout=10
)
providers.raise_for_status()
return models_ready(providers.json(), self.required_models)
except (httpx.HTTPError, TypeError, ValueError):
return False
async def create_session(self, workspace: Path, title: str) -> str:
response = await self._request(
"POST",
"/session",
workspace=workspace,
json={"title": f"Agent CI: {title.removesuffix('.json')}"},
)
session_id = response.get("id")
if not isinstance(session_id, str) or not session_id:
raise OpenCodeError("OpenCode did not return a session ID")
return session_id
async def resume(
self,
*,
session_id: str,
prompt: str,
model: str,
variant: str | None,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
self._active_sessions[session_id] = workspace
try:
return await self._prompt(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
except (asyncio.CancelledError, OpenCodeError):
try:
await asyncio.shield(self.abort(session_id, workspace))
except OpenCodeError as exc:
log.warning("OpenCode failed session could not be aborted", exc_info=exc)
raise
finally:
self._active_sessions.pop(session_id, None)
async def _prompt(
self,
*,
session_id: str,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> T:
try:
schema = load_schema(self.schemas_dir, schema_name)
except ValueError as exc:
raise OpenCodeError(str(exc)) from exc
provider_id, model_id = model_parts(model)
repair = "The previous response did not produce the required structured result. "
repair += "Return the requested result now without repeating repository work."
for attempt, message in enumerate((prompt, repair), start=1):
payload: dict[str, Any] = {
"model": {"providerID": provider_id, "modelID": model_id},
"agent": "build",
"parts": [{"type": "text", "text": message}],
"format": {"type": "json_schema", "schema": schema, "retryCount": 0},
}
if variant:
payload["variant"] = variant
started = monotonic()
extra = {"operation": "opencode.prompt", "attempt": attempt}
log.info("OpenCode turn started", extra=extra)
try:
response = await self._request(
"POST",
f"/session/{session_id}/message",
workspace=workspace,
json=payload,
)
except httpx.TimeoutException as exc:
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
raise OpenCodeError(message) from exc
info = response.get("info")
if not isinstance(info, dict):
raise OpenCodeError("OpenCode response did not include assistant metadata")
error = error_message(info.get("error"))
structured = info.get("structured")
validation_failed = False
if structured is not None:
try:
result = result_type.model_validate(structured)
except ValidationError as exc:
error = f"structured result failed validation: {exc}"
validation_failed = True
else:
extra["duration_ms"] = elapsed_ms(started)
log.info("OpenCode turn completed", extra=extra)
return result
if attempt == 2 or (
error and "StructuredOutput" not in error and not validation_failed
):
raise OpenCodeError(f"OpenCode did not return a valid result: {error or 'missing'}")
log.warning("OpenCode structured result will be retried", extra=extra)
raise OpenCodeError("OpenCode did not return a valid result")
async def _request(
self,
method: str,
path: str,
*,
workspace: Path,
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
response = await self.client.request(
method, path, headers=directory_headers(workspace), json=json
)
response.raise_for_status()
payload = response.json()
except httpx.TimeoutException:
raise
except (httpx.HTTPError, ValueError) as exc:
detail = ""
if isinstance(exc, httpx.HTTPStatusError):
detail = f": {exc.response.text[-2000:]}"
message = f"OpenCode request failed: {method} {path}{detail}"
raise OpenCodeError(message) from exc
if not isinstance(payload, dict):
raise OpenCodeError(f"OpenCode returned an invalid response for {method} {path}")
return payload
async def abort(self, session_id: str, workspace: Path, *, best_effort: bool = False) -> None:
try:
response = await self.client.post(
f"/session/{session_id}/abort", headers=directory_headers(workspace), timeout=10
)
if response.is_success or response.status_code in {404, 409}:
return
response.raise_for_status()
except httpx.HTTPError as exc:
if best_effort:
log.warning("OpenCode session could not be aborted", exc_info=exc)
return
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
@@ -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,2 @@
ALTER TABLE workflows ADD COLUMN runtime TEXT NOT NULL DEFAULT 'codex';
ALTER TABLE jobs ADD COLUMN runtime_session_id TEXT;
@@ -0,0 +1,96 @@
PRAGMA foreign_keys=OFF;
ALTER TABLE jobs RENAME TO jobs_legacy;
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
kind TEXT,
target_key TEXT NOT NULL,
repo_owner TEXT NOT NULL,
repo_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
pr_number INTEGER,
requester TEXT NOT NULL,
message TEXT,
comment_id INTEGER NOT NULL,
delivery_id TEXT NOT NULL UNIQUE,
receive_sequence INTEGER NOT NULL UNIQUE,
command_body TEXT NOT NULL,
workflow_id TEXT REFERENCES workflows(id),
status TEXT NOT NULL,
stage TEXT NOT NULL,
error TEXT,
runtime_session_id TEXT,
accepted_comment_id INTEGER,
started_comment_id INTEGER,
comment_body TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
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,
started_comment_id, created_at, started_at, finished_at
)
SELECT j.id, j.kind, j.target_key, j.repo_owner, j.repo_name, j.issue_number,
j.pr_number, j.requester, j.message, j.comment_id,
COALESCE(d.delivery_id, 'legacy:' || j.id),
ROW_NUMBER() OVER (ORDER BY j.created_at, j.id),
'/agent ' || CASE j.kind
WHEN 'iterate_plan' THEN 'iterate'
WHEN 'iterate_implement' THEN 'iterate'
ELSE j.kind END || CASE WHEN j.message = '' THEN '' ELSE ' ' || j.message END,
j.workflow_id, j.status, j.stage, j.error, j.runtime_session_id,
j.accepted_comment_id, j.started_comment_id, j.created_at, j.started_at, j.finished_at
FROM jobs_legacy j
LEFT JOIN deliveries d ON d.comment_id = j.comment_id;
CREATE TABLE job_events (
event_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) DEFERRABLE INITIALLY DEFERRED,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO job_events(event_id, job_id, event_type, payload_json, created_at)
SELECT 'delivery:' || delivery_id, id, 'legacy', '{}', created_at FROM jobs;
CREATE TABLE listener_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES jobs(id),
source_event_id TEXT NOT NULL REFERENCES job_events(event_id),
ordinal INTEGER NOT NULL,
listener TEXT NOT NULL,
queue TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
available_at TEXT NOT NULL,
error TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
UNIQUE(source_event_id, listener, ordinal)
);
INSERT INTO listener_tasks(
job_id, source_event_id, ordinal, listener, queue, status, available_at, created_at
)
SELECT id, 'delivery:' || delivery_id, 0, 'execute', 'jobs', 'pending', created_at, created_at
FROM jobs WHERE status = 'queued';
INSERT INTO listener_tasks(
job_id, source_event_id, ordinal, listener, queue, status, available_at, created_at
)
SELECT id, 'delivery:' || delivery_id, 1, 'reconcile_comment', 'control', 'pending',
created_at, created_at
FROM jobs WHERE status = 'queued';
DROP TABLE jobs_legacy;
CREATE INDEX idx_jobs_queue ON jobs(status, receive_sequence);
CREATE INDEX idx_jobs_target ON jobs(target_key, status);
CREATE INDEX idx_listener_eligible ON listener_tasks(queue, status, available_at, id);
PRAGMA foreign_keys=ON;
+1
View File
@@ -0,0 +1 @@
"""Service observability configuration."""
@@ -15,6 +15,7 @@ LOG_FIELDS = (
"method", "method",
"path", "path",
"status_code", "status_code",
"error_message",
"attempt", "attempt",
"item_count", "item_count",
"duration_ms", "duration_ms",
@@ -37,9 +38,14 @@ class JsonFormatter(logging.Formatter):
try: try:
return json.dumps(payload, ensure_ascii=False) return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError): except (TypeError, ValueError):
payload["message"] = "Log record could not be serialized" fallback = {
payload["exception"] = traceback.format_exc() "timestamp": payload["timestamp"],
return json.dumps(payload, ensure_ascii=False) "level": payload["level"],
"logger": payload["logger"],
"message": "Log record could not be serialized",
"exception": traceback.format_exc(),
}
return json.dumps(fallback, ensure_ascii=False)
def configure_logging() -> None: def configure_logging() -> None:
@@ -49,3 +55,5 @@ def configure_logging() -> None:
root.handlers.clear() root.handlers.clear()
root.addHandler(handler) root.addHandler(handler)
root.setLevel(logging.INFO) root.setLevel(logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+1 -14
View File
@@ -1,14 +1 @@
from __future__ import annotations """Prompt templates and structured-output contracts."""
from pathlib import Path
from string import Template
class PromptLibrary:
def __init__(self, directory: Path | None = None) -> None:
self.directory = directory or Path(__file__).parent
def render(self, name: str, **values: str) -> str:
template = Template((self.directory / f"{name}.md").read_text())
return template.substitute(values)
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from pathlib import Path
from string import Template
class PromptLibrary:
def __init__(self, directory: Path | None = None) -> None:
self.directory = directory or Path(__file__).parent
def render(self, name: str, **values: str) -> str:
template = Template((self.directory / f"{name}.md").read_text())
return template.substitute(values)
@property
def schemas_dir(self) -> Path:
return self.directory / "schemas"
-158
View File
@@ -1,158 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from agentci.adapters.codex import CodexClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.storage import Storage
from agentci.domain.models import Job, JobStatus
from agentci.workflows.common import JobRejected
from agentci.workflows.dispatcher import Dispatcher
log = logging.getLogger(__name__)
class Worker:
def __init__(
self,
*,
storage: Storage,
gitea: GiteaClient,
codex: CodexClient,
dispatcher: Dispatcher,
poll_seconds: float,
) -> None:
self.storage = storage
self.gitea = gitea
self.codex = codex
self.dispatcher = dispatcher
self.poll_seconds = poll_seconds
async def run(self, stop: asyncio.Event) -> None:
log.info("worker started", extra={"operation": "worker.run"})
await self._report_interrupted()
try:
while not stop.is_set():
if not await self.codex.login_ready():
log.warning(
"worker waiting for Codex authentication",
extra={"operation": "worker.poll"},
)
await self._wait(stop)
continue
job = await self.storage.claim_next()
if job is None:
await self._wait(stop)
continue
await self._run_job(job)
except Exception:
log.exception("worker stopped unexpectedly", extra={"operation": "worker.run"})
raise
finally:
log.info("worker stopped", extra={"operation": "worker.run"})
async def _run_job(self, job: Job) -> None:
extra = {"job_id": job.id, "target": job.target_key}
log.info("job started", extra=extra)
try:
if job.accepted_comment_id is None:
accepted_id = await self.gitea.create_comment(
job.repo_owner,
job.repo_name,
job.issue_number,
f"Agent job `{job.id}` queued (`{job.kind}`).",
)
await self.storage.set_job_comment(
job.id, "accepted_comment_id", accepted_id
)
comment_id = await self.gitea.create_comment(
job.repo_owner,
job.repo_name,
job.issue_number,
f"Agent job `{job.id}` started (`{job.kind}`).",
)
await self.storage.set_job_comment(job.id, "started_comment_id", comment_id)
await self.dispatcher.dispatch(job)
except JobRejected as exc:
await self._safe_update_job(
job, status=JobStatus.REJECTED, stage="rejected", error=str(exc)
)
await self._safe_comment(job, f"Agent job `{job.id}` was rejected: {exc}")
log.info("job rejected", extra={**extra, "stage": "rejected"})
except Exception as exc:
failed_stage = await self._safe_job_stage(job)
await self._safe_update_job(
job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc)
)
await self._safe_fail_workflow(job)
await self._safe_comment(
job,
f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}",
)
log.exception("job failed", extra={**extra, "stage": failed_stage})
else:
await self._safe_update_job(
job, status=JobStatus.SUCCEEDED, stage="completed"
)
log.info("job completed", extra=extra)
async def _report_interrupted(self) -> None:
jobs = await self.storage.recover_running()
if jobs:
log.warning(
"recovering interrupted jobs",
extra={"operation": "worker.recover", "item_count": len(jobs)},
)
for job in jobs:
await self._safe_fail_workflow(job)
await self._safe_comment(
job,
f"Agent job `{job.id}` failed because the service restarted during execution.",
)
async def _safe_comment(self, job: Job, body: str) -> None:
try:
await self.gitea.create_comment(
job.repo_owner, job.repo_name, job.issue_number, body
)
except Exception:
log.exception("could not publish job status", extra={"job_id": job.id})
async def _safe_job_stage(self, job: Job) -> str:
try:
return await self.storage.job_stage(job.id)
except Exception:
log.exception("could not read failed job stage", extra={"job_id": job.id})
return job.stage or "unknown"
async def _safe_update_job(
self,
job: Job,
*,
status: JobStatus,
stage: str,
error: str | None = None,
) -> None:
try:
await self.storage.update_job(
job.id, status=status, stage=stage, error=error
)
except Exception:
log.exception("could not persist job status", extra={"job_id": job.id})
async def _safe_fail_workflow(self, job: Job) -> None:
try:
await self.storage.fail_job_workflow(job.id)
except Exception:
log.exception("could not mark workflow failed", extra={"job_id": job.id})
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 -2
View File
@@ -1,2 +1 @@
"""Codex workflow orchestration.""" """Functional workflow orchestration."""
-57
View File
@@ -1,57 +0,0 @@
from __future__ import annotations
from pathlib import Path
from agentci.domain.models import AgentResult, Job
from agentci.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 self.deps.storage.update_job(job.id, stage="validating changes")
if not await self.deps.git.has_changes(workspace):
raise JobRejected("Codex completed without producing any file changes.")
await self.deps.git.diff_check(workspace)
title = _commit_title(result.summary_markdown)
await self.deps.storage.update_job(job.id, stage="committing changes")
sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}")
await self.deps.storage.update_job(job.id, stage="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"
-100
View File
@@ -1,100 +0,0 @@
from __future__ import annotations
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
from agentci.workflows.common import Dependencies, report_for_prompt, report_json
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 self.deps.storage.update_job(
job.id,
stage=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.codex.resume(
session_id=_required(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
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.codex.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-review",
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id, report = await self.deps.codex.start(
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-review",
schema_name="review.json",
result_type=ReviewReport,
)
workflow.reviewer_session_id = session_id
return report
def _required(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted Codex session ID")
return value
-65
View File
@@ -1,65 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from agentci.adapters.codex import CodexClient
from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.domain.models import ReviewReport
from agentci.prompts import PromptLibrary
from agentci.workflows.context import ContextBuilder
class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
@dataclass(frozen=True)
class Dependencies:
settings: Settings
storage: Storage
gitea: GiteaClient
git: GitClient
codex: CodexClient
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}"
+55 -32
View File
@@ -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(
) -> tuple[PullRequestInfo, str]: gitea: Gitea,
pull = await self.gitea.pull_request(owner, repo, number) owner: str,
timeline = await self.gitea.issue_comments(owner, repo, number) repo: str,
reviews = await self.gitea.pull_reviews(owner, repo, number) number: int,
commits = await self.gitea.pull_commits(owner, repo, number) ) -> tuple[PullRequestInfo, str]:
review_text = await self._format_reviews(owner, repo, number, reviews) pull, timeline, reviews, commits = await asyncio.gather(
gitea.pull_request(owner, repo, number),
gitea.issue_comments(owner, repo, number),
gitea.pull_reviews(owner, repo, number),
gitea.pull_commits(owner, repo, number),
)
review_text = await _format_reviews(gitea, owner, repo, number, reviews)
timeline_text = "\n\n".join(_format_comment(item) for item in timeline) 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(
self, async def _format_reviews(
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}"
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import logging
from agentci.engine.model import Job, JobKind
from agentci.engine.run import JobRun
from agentci.workflows.implementation import implement
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__)
async def dispatch(job: Job, run: JobRun, services: WorkflowServices) -> str:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
"target": job.target_key,
"stage": job.kind.value,
}
log.info("workflow dispatch started", extra=extra)
try:
match job.kind:
case JobKind.PLAN:
body = await create_plan(job, run, services)
case JobKind.DISCUSS:
body = await discuss_plan(job, run, services)
case JobKind.ITERATE_PLAN:
body = await iterate_plan(job, run, services)
case JobKind.IMPLEMENT:
body = await implement(job, run, services)
case JobKind.ITERATE_IMPLEMENT:
body = await iterate_implementation(job, run, services)
case JobKind.FIX:
body = await fix_pull_request(job, run, services)
case _:
raise KeyError(job.kind)
except Exception:
log.exception("workflow dispatch failed", extra=extra)
raise
log.info("workflow dispatch completed", extra=extra)
return body
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
import logging
from agentci.domain.models import Job, JobKind
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 = {
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: Job) -> None:
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)
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
from uuid import uuid4
from agentci.domain.models import (
AgentResult,
Job,
Workflow,
WorkflowKind,
WorkflowStatus,
)
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,
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 self.deps.storage.update_job(job.id, stage="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 self.deps.storage.create_workflow(workflow)
job.workflow_id = workflow.id
await self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="installing development environment"
)
await self.deps.development.prepare(workspace)
await self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="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, result = await self.deps.codex.start(
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow.primary_session_id = session_id
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 self.deps.storage.update_job(job.id, stage="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)}"
await self.deps.gitea.create_comment(
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("implementation", workflow.id, body),
)
remaining = review_markdown(report)
if remaining:
await self.deps.gitea.create_comment(
job.repo_owner, job.repo_name, pull.number, remaining
)
async def _reject_duplicate(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await self.deps.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open:
raise JobRejected(
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
"on that pull request."
)
if pull.merged:
raise JobRejected(
f"Agent PR #{pull.number} has already been merged for this issue."
)
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from uuid import uuid4
from agentci.engine.model import Job, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.workflows.context import build_issue_context
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected,
commit_title,
final_comment,
pull_request_body,
result_comment,
)
from agentci.workflows.review import review_implementation_loop
from agentci.workflows.services import WorkflowServices
async def implement(job: Job, run: JobRun, services: WorkflowServices) -> str:
await _reject_duplicate(job, services)
default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
issue = await services.gitea.issue(job.repo_owner, job.repo_name, job.issue_number)
workflow_id = str(uuid4())
branch = (
f"{services.settings.branch_prefix}/issue-{job.issue_number}-"
f"{workflow_id[:8]}"
)
workspace = services.settings.workspaces_dir / workflow_id / "repo"
await run.stage("cloning")
base_sha = await services.git.clone(
job.repo_owner,
job.repo_name,
default_branch,
workspace,
)
await services.git.create_branch(workspace, branch)
workflow = Workflow(
id=workflow_id,
kind=WorkflowKind.IMPLEMENT,
repo_owner=job.repo_owner,
repo_name=job.repo_name,
issue_number=job.issue_number,
workspace_path=workspace,
base_sha=base_sha,
branch=branch,
)
await run.create_workflow(workflow, "installing development environment")
await services.development.prepare(workspace)
await run.stage("implementing")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
plan = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
job.issue_number,
WorkflowKind.PLAN,
)
plan_artifact = plan.artifact if plan and plan.artifact else "(no canonical plan)"
prompt = services.prompts.render(
"implement_initial",
context=context,
artifact=plan_artifact,
request=job.message or "(no additional request)",
development_environment=services.development.description,
)
session_id = await services.opencode.create_session(workspace, "implementation")
workflow = replace(workflow, primary_session_id=session_id)
await services.repository.save_workflow(workflow)
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow = replace(workflow, artifact=result.model_dump_json())
await services.repository.save_workflow(workflow)
workflow, result, report = await review_implementation_loop(
workflow,
context,
plan_artifact,
result,
run,
services,
)
sha = await commit_and_push(
run,
services,
workspace,
branch,
result,
set_upstream=True,
commit_prefix="agent",
)
await run.stage("creating pull request")
pull = await services.gitea.create_pull_request(
job.repo_owner,
job.repo_name,
title=f"Agent: {issue.title}",
body=pull_request_body(job.issue_number, result),
head=branch,
base=default_branch,
)
workflow = replace(
workflow,
pr_number=pull.number,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
pull_url = (
f"{services.settings.gitea_url}/{job.repo_owner}/"
f"{job.repo_name}/pulls/{pull.number}"
)
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
return final_comment("implementation", workflow.id, body, report)
async def commit_and_push(
run: JobRun,
services: WorkflowServices,
workspace: Path,
branch: str,
result: AgentResult,
*,
set_upstream: bool,
commit_prefix: str,
) -> str:
await run.stage("validating changes")
if not await services.git.has_changes(workspace):
raise JobRejected("OpenCode completed without producing any file changes.")
await services.git.diff_check(workspace)
title = commit_title(result.summary_markdown)
await run.stage("committing changes")
sha = await services.git.commit(workspace, f"{commit_prefix}: {title}")
await run.stage("pushing changes")
await services.git.push(workspace, branch, set_upstream=set_upstream)
return sha
async def _reject_duplicate(job: Job, services: WorkflowServices) -> None:
workflows = await services.repository.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await services.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open:
raise JobRejected(
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
"on that pull request."
)
if pull.merged:
raise JobRejected(
f"Agent PR #{pull.number} has already been merged for this issue."
)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, Field
class ReviewSeverity(StrEnum):
BLOCKING = "blocking"
MAJOR = "major"
MINOR = "minor"
class PlanArtifact(BaseModel):
plan_markdown: str = Field(min_length=1)
class DiscussionReply(BaseModel):
markdown: str = Field(min_length=1)
class AgentResult(BaseModel):
summary_markdown: str = Field(min_length=1)
tests: list[str] = Field(default_factory=list)
class ReviewFinding(BaseModel):
severity: ReviewSeverity
title: str
detail: str
location: str | None = None
recommendation: str
class ReviewReport(BaseModel):
summary: str
findings: list[ReviewFinding] = Field(default_factory=list)
@property
def has_serious_findings(self) -> bool:
return any(
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
for finding in self.findings
)
+98 -156
View File
@@ -1,39 +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.workflows.common import ( from agentci.engine.run import JobRun
Dependencies, from agentci.workflows.context import build_issue_context
from agentci.workflows.model import DiscussionReply, PlanArtifact
from agentci.workflows.render import (
JobRejected, JobRejected,
agent_comment, agent_comment,
final_comment,
report_for_prompt, report_for_prompt,
report_json,
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 self.deps.storage.update_job(job.id, stage="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(
@@ -45,193 +43,143 @@ class PlanWorkflow:
workspace_path=workspace, workspace_path=workspace,
base_sha=base_sha, base_sha=base_sha,
) )
await self.deps.storage.create_workflow(workflow) await run.create_workflow(workflow, "planning")
job.workflow_id = workflow.id
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="planning") context = await build_issue_context(
context = await self.deps.context.issue_context( services.gitea,
job.repo_owner, job.repo_name, job.issue_number 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, artifact = await self.deps.codex.start( session_id = await services.opencode.create_session(workspace, "plan")
workflow = replace(workflow, primary_session_id=session_id)
await services.repository.save_workflow(workflow)
await run.link_session(session_id)
artifact = await services.opencode.resume(
session_id=session_id,
workspace=workspace, workspace=workspace,
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=services.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=services.settings.plan_variant,
permission="agentci-read",
schema_name="plan.json", schema_name="plan.json",
result_type=PlanArtifact, result_type=PlanArtifact,
) )
workflow.primary_session_id = session_id workflow = replace(workflow, artifact=artifact.plan_markdown)
workflow.artifact = artifact.plan_markdown await services.repository.save_workflow(workflow)
await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
async def discuss(self, job: Job) -> None: 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":
raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; "
"start a new `/agent plan`."
)
if not workflow.primary_session_id or not workflow.artifact: 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 self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="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.codex.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,
reasoning=self.deps.settings.plan_reasoning, variant=services.settings.plan_variant,
permission="agentci-read",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="discussion.json", schema_name="discussion.json",
result_type=DiscussionReply, result_type=DiscussionReply,
) )
await self.deps.gitea.create_comment( return agent_comment("discussion", workflow.id, reply.markdown)
job.repo_owner,
job.repo_name,
job.issue_number,
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":
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 self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="iterating plan" await run.link_workflow(workflow.id, "iterating plan")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
) )
context = await self.deps.context.issue_context( prompt = services.prompts.render(
job.repo_owner, job.repo_name, job.issue_number
)
prompt = self.deps.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.codex.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,
reasoning=self.deps.settings.plan_reasoning, variant=services.settings.plan_variant,
permission="agentci-read",
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 self.deps.storage.update_job(
job.id,
stage=f"reviewing plan {round_index + 1}/"
f"{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.codex.resume( await services.repository.save_workflow(workflow)
session_id=_required(workflow.primary_session_id), return final_comment("plan", workflow.id, artifact.plan_markdown, report)
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
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.codex.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-review",
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id, report = await self.deps.codex.start(
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-review",
schema_name="review.json",
result_type=ReviewReport,
)
workflow.reviewer_session_id = session_id
return report
async def _finish( async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow:
self, workflow = await services.repository.latest_workflow(
job: Job,
workflow: Workflow,
artifact: PlanArtifact,
report: ReviewReport,
) -> None:
workflow.artifact = artifact.plan_markdown
workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow)
await self.deps.gitea.create_comment(
job.repo_owner, job.repo_owner,
job.repo_name, job.repo_name,
job.issue_number, job.issue_number,
agent_comment("plan", workflow.id, artifact.plan_markdown), WorkflowKind.PLAN,
)
remaining = review_markdown(report)
if remaining:
await self.deps.gitea.create_comment(
job.repo_owner, job.repo_name, job.issue_number, remaining
)
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:
@@ -239,9 +187,3 @@ class PlanWorkflow:
f"Issue plan iteration is disabled because agent PR #{pull.number} " f"Issue plan iteration is disabled because agent PR #{pull.number} "
"is open or merged. Iterate an open implementation on its PR." "is open or merged. Iterate an open implementation on its PR."
) )
def _required(value: str | None) -> str:
if value is None: # Defensive: workflows with missing sessions are rejected earlier.
raise RuntimeError("Expected a persisted Codex session ID")
return value
+84 -77
View File
@@ -1,153 +1,160 @@
from __future__ import annotations from __future__ import annotations
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus from dataclasses import replace
from agentci.workflows.change_set import ChangeSet, result_comment
from agentci.workflows.code_review import CodeReviewLoop from agentci.engine.model import Job, WorkflowKind, WorkflowStatus
from agentci.workflows.common import ( from agentci.engine.run import JobRun
Dependencies, from agentci.workflows.context import (
build_issue_context,
build_pull_request_context,
)
from agentci.workflows.implementation import commit_and_push
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected, JobRejected,
agent_comment, agent_comment,
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:
raise JobRejected( raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`." "This is not an open agent-created implementation PR. Use `/agent fix`."
) )
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id: 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 self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="synchronizing branch" await run.link_workflow(workflow.id, "synchronizing branch")
) await services.git.sync_branch(workflow.workspace_path, pull.head_branch)
job.workflow_id = workflow.id await run.stage("installing development environment")
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch) await services.development.prepare(workflow.workspace_path)
await self.deps.storage.update_job( await run.stage("implementing iteration")
job.id, stage="installing development environment" prompt = services.prompts.render(
)
await self.deps.development.prepare(workflow.workspace_path)
await self.deps.storage.update_job(job.id, stage="implementing iteration")
prompt = self.deps.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.codex.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,
reasoning=self.deps.settings.implement_reasoning, variant=services.settings.implement_variant,
permission="agentci-write",
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(),
await self.deps.gitea.create_comment( review_json=report.model_dump_json(),
job.repo_owner,
job.repo_name,
pull_number,
agent_comment("iteration", workflow.id, result_comment(result, sha=sha)),
) )
remaining = review_markdown(report) await services.repository.save_workflow(workflow)
if remaining: return final_comment(
await self.deps.gitea.create_comment( "iteration", workflow.id, result_comment(result, sha=sha), report
job.repo_owner, job.repo_name, pull_number, remaining
) )
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 self.deps.storage.update_job(job.id, stage="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 self.deps.storage.update_job( await run.stage("installing development environment")
job.id, stage="installing development environment" await services.development.prepare(workspace)
) prompt = services.prompts.render(
await self.deps.development.prepare(workspace)
prompt = self.deps.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 self.deps.storage.update_job(job.id, stage="fixing") await run.stage("fixing")
_, result = await self.deps.codex.start( session_id = await services.opencode.create_session(workspace, "fix")
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace, workspace=workspace,
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=services.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=services.settings.implement_variant,
permission="agentci-write",
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 self.deps.gitea.create_comment( return agent_comment("fix", job.id, result_comment(result, sha=sha))
job.repo_owner,
job.repo_name,
pull_number,
agent_comment("fix", job.id, result_comment(result, sha=sha)),
)
def _pull_number(job: Job) -> int: def _pull_number(job: Job) -> int:
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import json
from agentci.workflows.model import AgentResult, ReviewReport
class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
def review_markdown(report: ReviewReport) -> str:
if not report.findings:
return ""
lines = ["## Remaining review findings", "", report.summary]
for finding in report.findings:
location = f" \u2014 `{finding.location}`" if finding.location else ""
lines.extend(
[
"",
f"### {finding.severity.value.upper()}: {finding.title}{location}",
finding.detail,
"",
f"Recommendation: {finding.recommendation}",
]
)
return "\n".join(lines)
def report_for_prompt(stored_review: str | None) -> str:
if not stored_review:
return "(none)"
try:
return json.dumps(json.loads(stored_review), indent=2)
except json.JSONDecodeError:
return stored_review
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
def final_comment(
kind: str,
workflow_id: str,
body: str,
report: ReviewReport | None = None,
) -> str:
rendered = agent_comment(kind, workflow_id, body)
remaining = review_markdown(report) if report else ""
return f"{rendered}\n\n{remaining}" if remaining else rendered
def pull_request_body(issue_number: int, result: AgentResult) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
return (
f"Closes #{issue_number}\n\n"
f"## Implementation\n\n{result.summary_markdown}\n\n"
f"## Validation\n\n{tests}\n\n"
"_Created by Agent CI._"
)
def result_comment(result: AgentResult, *, sha: str | None = None) -> str:
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
commit = f"\n\nCommit: `{sha}`" if sha else ""
return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}"
def commit_title(markdown: str) -> str:
for line in markdown.splitlines():
value = line.strip().lstrip("#").strip()
if value:
return value[:72]
return "apply requested changes"
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from dataclasses import replace
from agentci.engine.model import Workflow
from agentci.engine.run import JobRun
from agentci.workflows.model import AgentResult, PlanArtifact, ReviewReport
from agentci.workflows.render import report_for_prompt, required_session
from agentci.workflows.services import WorkflowServices
async def review_plan_loop(
workflow: Workflow,
context: str,
artifact: PlanArtifact,
run: JobRun,
services: WorkflowServices,
) -> tuple[Workflow, PlanArtifact, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(services.settings.plan_review_rounds):
await run.stage(
f"reviewing plan {round_index + 1}/{services.settings.plan_review_rounds}"
)
workflow, report = await review_plan_once(
workflow, context, artifact, services
)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == services.settings.plan_review_rounds - 1:
break
prompt = services.prompts.render(
"plan_revision",
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await services.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow = replace(workflow, artifact=artifact.plan_markdown)
await services.repository.save_workflow(workflow)
return workflow, artifact, report
async def review_plan_once(
workflow: Workflow,
context: str,
artifact: PlanArtifact,
services: WorkflowServices,
) -> tuple[Workflow, ReviewReport]:
prompt = services.prompts.render(
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
report = await services.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
session_id = await services.opencode.create_session(
workflow.workspace_path, "plan-review"
)
workflow = replace(workflow, reviewer_session_id=session_id)
await services.repository.save_workflow(workflow)
report = await services.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=services.settings.plan_model,
variant=services.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
async def review_implementation_loop(
workflow: Workflow,
issue_context: str,
plan: str,
result: AgentResult,
run: JobRun,
services: WorkflowServices,
) -> tuple[Workflow, AgentResult, ReviewReport]:
report = ReviewReport(summary="", findings=[])
for round_index in range(services.settings.implement_review_rounds):
await run.stage(
f"reviewing implementation {round_index + 1}/"
f"{services.settings.implement_review_rounds}"
)
workflow, report = await review_implementation_once(
workflow,
issue_context=issue_context,
plan=plan,
pull_context=(
"The proposed pull request is the current uncommitted working-tree diff. "
"Review only that diff."
),
services=services,
)
workflow = replace(
workflow,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
)
await services.repository.save_workflow(workflow)
if not report.has_serious_findings:
break
if round_index == services.settings.implement_review_rounds - 1:
break
prompt = services.prompts.render(
"implementation_revision",
review=report_for_prompt(workflow.review_json),
development_environment=services.development.description,
)
result = await services.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
workflow = replace(workflow, artifact=result.model_dump_json())
await services.repository.save_workflow(workflow)
return workflow, result, report
async def review_implementation_once(
workflow: Workflow,
*,
issue_context: str,
plan: str,
pull_context: str,
services: WorkflowServices,
) -> tuple[Workflow, ReviewReport]:
prompt = services.prompts.render(
"implementation_review",
issue_context=issue_context,
artifact=plan,
pull_context=pull_context,
)
if workflow.reviewer_session_id:
report = await services.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
session_id = await services.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow = replace(workflow, reviewer_session_id=session_id)
await services.repository.save_workflow(workflow)
report = await services.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="review.json",
result_type=ReviewReport,
)
return workflow, report
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from agentci.config.settings import Settings
from agentci.engine.repository import Repository
from agentci.integrations.development import DevelopmentEnvironment
from agentci.integrations.git import Git
from agentci.integrations.gitea.client import Gitea
from agentci.integrations.opencode.client import OpenCode
from agentci.prompts.library import PromptLibrary
@dataclass(frozen=True)
class WorkflowServices:
settings: Settings
repository: Repository
gitea: Gitea
git: Git
opencode: OpenCode
prompts: PromptLibrary
development: DevelopmentEnvironment
+130
View File
@@ -0,0 +1,130 @@
import asyncio
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
import agentci.api.app as app_module
import agentci.api.lifespan as lifespan_module
class BlockingWorker:
def __init__(self, events: list[str]) -> None:
self.events = events
self.started = asyncio.Event()
async def run(self, stop: asyncio.Event) -> None:
self.events.append("worker-started")
self.started.set()
try:
await stop.wait()
except asyncio.CancelledError:
self.events.append(f"worker-cancelled:{stop.is_set()}")
raise
class FailingWorker:
def __init__(self) -> None:
self.started = asyncio.Event()
async def run(self, _stop: asyncio.Event) -> None:
self.started.set()
raise RuntimeError("worker failed")
class FakeRuntime:
def __init__(self, worker: object, events: list[str]) -> None:
self.worker = worker
self.events = events
self.closed = False
async def close(self) -> None:
self.events.append("runtime-closed")
self.closed = True
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
worker = BlockingWorker(events)
runtime = FakeRuntime(worker, events)
selected_settings = SimpleNamespace(name="selected")
built_with: list[object] = []
configured: list[bool] = []
async def build(settings: object) -> FakeRuntime:
built_with.append(settings)
return runtime
monkeypatch.setattr(lifespan_module, "build_runtime", build)
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
async with application.router.lifespan_context(application):
await worker.started.wait()
assert application.state.runtime is runtime
assert not runtime.closed
assert built_with == [selected_settings]
assert configured == [True]
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
async def test_lifespan_closes_runtime_when_worker_task_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
worker = FailingWorker()
runtime = FakeRuntime(worker, events)
async def build(_settings: object) -> FakeRuntime:
return runtime
monkeypatch.setattr(lifespan_module, "build_runtime", build)
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None)
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="worker failed"):
async with application.router.lifespan_context(application):
await worker.started.wait()
assert runtime.closed
assert events == ["runtime-closed"]
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
configured: list[bool] = []
async def fail_build(_settings: object) -> None:
raise RuntimeError("database unavailable")
monkeypatch.setattr(lifespan_module, "build_runtime", fail_build)
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="database unavailable"):
async with application.router.lifespan_context(application):
pytest.fail("startup failure must prevent serving requests")
assert configured == [True]
assert not hasattr(application.state, "runtime")
async def test_global_exception_handler_returns_safe_json() -> None:
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
@application.get("/explode")
async def explode() -> None:
raise RuntimeError("sensitive provider detail")
async with AsyncClient(
transport=ASGITransport(app=application, raise_app_exceptions=False),
base_url="http://test",
) as client:
response = await client.get("/explode")
assert response.status_code == 500
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
+347 -89
View File
@@ -1,22 +1,30 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast
from agentci.domain.models import ( from pydantic import BaseModel
from agentci.engine.model import Workflow, WorkflowKind
from agentci.engine.run import JobRun
from agentci.workflows.model import (
AgentResult, AgentResult,
Job, PlanArtifact,
JobKind,
ReviewFinding, ReviewFinding,
ReviewReport, ReviewReport,
ReviewSeverity, ReviewSeverity,
Workflow,
WorkflowKind,
) )
from agentci.workflows.code_review import CodeReviewLoop from agentci.workflows.review import (
review_implementation_loop,
review_implementation_once,
review_plan_loop,
review_plan_once,
)
from agentci.workflows.services import WorkflowServices
def serious_report() -> ReviewReport: def serious_report(summary: str = "Needs work") -> ReviewReport:
return ReviewReport( return ReviewReport(
summary="Needs work", summary=summary,
findings=[ findings=[
ReviewFinding( ReviewFinding(
severity=ReviewSeverity.MAJOR, severity=ReviewSeverity.MAJOR,
@@ -28,102 +36,352 @@ def serious_report() -> ReviewReport:
) )
class FakeCodex: 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 start(self, **_kwargs):
self.reviews += 1
return "reviewer", next(self.reports)
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]):
codex = FakeCodex(reports)
settings = SimpleNamespace(
implement_review_rounds=rounds,
implement_model="model",
implement_reasoning="high",
) )
deps = SimpleNamespace( ],
settings=settings,
codex=codex,
storage=FakeStorage(),
prompts=FakePrompts(),
development=SimpleNamespace(description="python"),
) )
workflow = Workflow(
class RecordingOpenCode:
def __init__(self, responses: list[BaseModel]) -> None:
self.responses = list(responses)
self.created_sessions: list[tuple[Path, str]] = []
self.resume_calls: list[dict[str, Any]] = []
async def create_session(self, workspace: Path, title: str) -> str:
self.created_sessions.append((workspace, title))
return f"{title}-session"
async def resume(self, **values: Any) -> BaseModel:
self.resume_calls.append(values)
response = self.responses.pop(0)
assert isinstance(response, values["result_type"])
return response
class RecordingRepository:
def __init__(self) -> None:
self.saved_workflows: list[Workflow] = []
async def save_workflow(self, workflow: Workflow) -> None:
self.saved_workflows.append(workflow)
class RecordingRun(JobRun):
def __init__(self) -> None:
self.stages: list[str] = []
async def stage(self, stage: str) -> None:
self.stages.append(stage)
class RecordingPrompts:
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, str]]] = []
def render(self, name: str, **values: str) -> str:
self.calls.append((name, values))
return f"rendered {name}"
def workflow(*, reviewer_session_id: str | None = None) -> Workflow:
return Workflow(
id="flow", 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), codex, workflow, job # type: ignore[arg-type]
async def test_stops_after_clean_second_review() -> None: def objects(
clean = ReviewReport(summary="Ready", findings=[]) responses: list[BaseModel],
loop, codex, workflow, job = objects(4, [serious_report(), clean]) *,
_, report = await loop.run( plan_rounds: int = 4,
job, implementation_rounds: int = 3,
workflow, ) -> tuple[
RecordingOpenCode,
RecordingRepository,
RecordingPrompts,
WorkflowServices,
]:
opencode = RecordingOpenCode(responses)
repository = RecordingRepository()
prompts = RecordingPrompts()
services = cast(
WorkflowServices,
SimpleNamespace(
settings=SimpleNamespace(
plan_review_rounds=plan_rounds,
plan_model="provider/plan",
plan_variant="high",
implement_review_rounds=implementation_rounds,
implement_model="provider/implement",
implement_variant="high",
),
opencode=opencode,
repository=repository,
prompts=prompts,
development=SimpleNamespace(description="Python 3.13"),
),
)
return opencode, repository, prompts, services
async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None:
revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"])
opencode, repository, prompts, services = objects(
[serious_report(), revised, clean_report()], implementation_rounds=4
)
run = RecordingRun()
original = workflow()
updated, result, report = await review_implementation_loop(
original,
"issue context", "issue context",
"canonical plan", "canonical plan",
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
run,
services,
) )
assert result == revised
assert report == clean_report()
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "implementation-review-session"
assert updated.artifact == revised.model_dump_json()
assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session")
assert run.stages == [
"reviewing implementation 1/4",
"reviewing implementation 2/4",
]
assert opencode.created_sessions == [(original.workspace_path, "implementation-review")]
assert [call["session_id"] for call in opencode.resume_calls] == [
"implementation-review-session",
"primary-session",
"implementation-review-session",
]
assert [call["result_type"] for call in opencode.resume_calls] == [
ReviewReport,
AgentResult,
ReviewReport,
]
assert [name for name, _ in prompts.calls] == [
"implementation_review",
"implementation_revision",
"implementation_review",
]
assert opencode.responses == []
async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None:
final_report = serious_report("Still failing after the last review")
opencode, repository, _, services = objects(
[
serious_report("round 1"),
AgentResult(summary_markdown="revision 1", tests=[]),
serious_report("round 2"),
AgentResult(summary_markdown="revision 2", tests=[]),
final_report,
],
implementation_rounds=3,
)
run = RecordingRun()
updated, result, report = await review_implementation_loop(
workflow(),
"issue context",
"canonical plan",
AgentResult(summary_markdown="initial", tests=[]),
run,
services,
)
assert result.summary_markdown == "revision 2"
assert report is final_report
assert updated.artifact == result.model_dump_json()
assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls].count(ReviewReport) == 3
assert [call["result_type"] for call in opencode.resume_calls].count(AgentResult) == 2
assert run.stages == [
"reviewing implementation 1/3",
"reviewing implementation 2/3",
"reviewing implementation 3/3",
]
assert opencode.responses == []
async def test_implementation_review_once_reuses_existing_reviewer_session() -> None:
opencode, repository, prompts, services = objects([clean_report()])
existing = workflow(reviewer_session_id="existing-reviewer")
updated, report = await review_implementation_once(
existing,
issue_context="issue context",
plan="canonical plan",
pull_context="pull request context",
services=services,
)
assert updated is existing
assert report == clean_report()
assert opencode.created_sessions == []
assert repository.saved_workflows == []
assert opencode.resume_calls == [
{
"session_id": "existing-reviewer",
"prompt": "rendered implementation_review",
"model": "provider/implement",
"variant": "high",
"workspace": existing.workspace_path,
"schema_name": "review.json",
"result_type": ReviewReport,
}
]
assert prompts.calls == [
(
"implementation_review",
{
"issue_context": "issue context",
"artifact": "canonical plan",
"pull_context": "pull request context",
},
)
]
async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None:
revised = PlanArtifact(plan_markdown="Revised plan")
opencode, repository, prompts, services = objects(
[serious_report(), revised, clean_report()], plan_rounds=4
)
run = RecordingRun()
original = workflow()
initial = PlanArtifact(plan_markdown="Initial plan")
updated, artifact, report = await review_plan_loop(
original, "issue context", initial, run, services
)
assert artifact is revised
assert report == clean_report()
assert updated.reviewer_session_id == "plan-review-session"
assert updated.artifact == "Revised plan"
assert updated.review_json == clean_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"]
assert [call["session_id"] for call in opencode.resume_calls] == [
"plan-review-session",
"primary-session",
"plan-review-session",
]
assert [name for name, _ in prompts.calls] == [
"plan_review",
"plan_revision",
"plan_review",
]
assert opencode.responses == []
async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None:
final_report = serious_report("round 2")
opencode, repository, _, services = objects(
[
serious_report("round 1"),
PlanArtifact(plan_markdown="Only revision"),
final_report,
],
plan_rounds=2,
)
run = RecordingRun()
updated, artifact, report = await review_plan_loop(
workflow(),
"issue context",
PlanArtifact(plan_markdown="Initial plan"),
run,
services,
)
assert artifact.plan_markdown == "Only revision"
assert report is final_report
assert updated.artifact == "Only revision"
assert updated.review_json == final_report.model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [
ReviewReport,
PlanArtifact,
ReviewReport,
]
assert run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"]
assert opencode.responses == []
async def test_plan_review_once_creates_and_persists_reviewer_session() -> None:
opencode, repository, prompts, services = objects([clean_report()])
original = workflow()
updated, report = await review_plan_once(
original,
"issue context",
PlanArtifact(plan_markdown="Plan body"),
services,
)
assert report == clean_report()
assert updated is not original
assert original.reviewer_session_id is None
assert updated.reviewer_session_id == "plan-review-session"
assert repository.saved_workflows == [updated]
assert opencode.created_sessions == [(original.workspace_path, "plan-review")]
assert opencode.resume_calls[0]["session_id"] == "plan-review-session"
assert opencode.resume_calls[0]["result_type"] is ReviewReport
assert prompts.calls == [
(
"plan_review",
{"context": "issue context", "artifact": "Plan body"},
)
]
async def test_minor_findings_end_review_loop_without_revision() -> None:
opencode, repository, _, services = objects([minor_report()], implementation_rounds=5)
run = RecordingRun()
initial = AgentResult(summary_markdown="initial", tests=[])
updated, result, report = await review_implementation_loop(
workflow(),
"issue context",
"canonical plan",
initial,
run,
services,
)
assert result is initial
assert report == minor_report()
assert not report.has_serious_findings assert not report.has_serious_findings
assert codex.reviews == 2 assert updated.artifact == initial.model_dump_json()
assert codex.revisions == 1 assert updated.review_json == minor_report().model_dump_json()
assert repository.saved_workflows[-1] == updated
assert [call["result_type"] for call in opencode.resume_calls] == [ReviewReport]
async def test_does_not_make_unreviewed_final_revision() -> None: assert run.stages == ["reviewing implementation 1/5"]
loop, codex, 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 codex.reviews == 3
assert codex.revisions == 2
+86 -7
View File
@@ -1,13 +1,17 @@
from pathlib import Path from pathlib import Path
from agentci.adapters.codegraph import CodeGraphClient import pytest
from agentci.integrations.codegraph import CodeGraph, CodeGraphError
class FakeProcess: class FakeProcess:
returncode = 0 def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
self.returncode = returncode
self.stderr = stderr
async def communicate(self) -> tuple[bytes, bytes]: async def communicate(self) -> tuple[bytes, bytes]:
return b"", b"" return b"", self.stderr
async def test_initializes_incomplete_index_and_excludes_it_from_git( async def test_initializes_incomplete_index_and_excludes_it_from_git(
@@ -23,11 +27,11 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
await CodeGraphClient().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "init", str(workspace))] assert calls == [("codegraph", "init", str(workspace))]
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n" assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
@@ -49,11 +53,86 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.adapters.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
await CodeGraphClient().prepare(workspace) await CodeGraph().prepare(workspace)
assert calls == [("codegraph", "sync", str(workspace))] assert calls == [("codegraph", "sync", str(workspace))]
assert exclude.read_text() == "# local excludes\n.codegraph/\n" assert exclude.read_text() == "# local excludes\n.codegraph/\n"
@pytest.mark.parametrize(
("initial", "expected"),
[
("*.pyc", "*.pyc\n.codegraph/\n"),
("# .codegraph/ is documented here\n", "# .codegraph/ is documented here\n.codegraph/\n"),
(" .codegraph/ \n", " .codegraph/ \n"),
],
)
async def test_handles_exclude_file_boundaries(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
initial: str,
expected: str,
) -> None:
workspace = tmp_path / "repo"
exclude = workspace / ".git" / "info" / "exclude"
exclude.parent.mkdir(parents=True)
exclude.write_text(initial)
async def create_subprocess_exec(*_args, **_kwargs):
return FakeProcess()
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
await CodeGraph().prepare(workspace)
assert exclude.read_text() == expected
async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
async def create_subprocess_exec(*_args, **_kwargs):
raise FileNotFoundError(2, "No such file or directory", "codegraph")
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised:
await CodeGraph().prepare(workspace)
assert isinstance(raised.value.__cause__, FileNotFoundError)
assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n"
async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail"
async def create_subprocess_exec(*_args, **_kwargs):
return FakeProcess(returncode=7, stderr=stderr)
monkeypatch.setattr(
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
with pytest.raises(CodeGraphError, match="codegraph init failed") as raised:
await CodeGraph().prepare(workspace)
message = str(raised.value)
assert "discarded-prefix" not in message
assert "useful-tail" in message
assert len(message.removeprefix("codegraph init failed: ")) == 1000

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