19 Commits
Author SHA1 Message Date
StanPonomarev ce9f1e3d20 refactor tests
Publish container image / Build and push (push) Successful in 32s
2026-07-26 23:49:40 +02:00
StanPonomarev 5ef10d28fe fix compose
Publish container image / Build and push (push) Successful in 33s
2026-07-25 13:16:32 +02:00
StanPonomarev 73243c1191 fix ci
Publish container image / Build and push (push) Successful in 31s
2026-07-25 12:40:02 +02:00
StanPonomarev fb11e1f181 Merge pull request 'Rewrite Agent CI around a durable state machine' (#4) from chore/rewrite into main
Reviewed-on: #4
Reviewed-by: StanPonomarev <dmss@krtss.de>
2026-07-25 12:36:32 +02:00
StanPonomarev d6a0010632 fix action tags 2026-07-25 12:36:15 +02:00
StanPonomarev 4de8c2624a ci and compose update 2026-07-25 12:33:53 +02:00
StanPonomarev 0526406472 reorg files 2026-07-22 23:49:44 +02:00
StanPonomarev 98ac4abca1 rewrite phase 1 2026-07-22 23:10:23 +02:00
StanPonomarev 7527831af6 make concurrent 2026-07-22 17:37:14 +02:00
StanPonomarev 18f364b069 Merge pull request 'Agent: Redesign the webhook host to be an explicit unambiguous state machine' (#2) from agent/issue-1-52b8278c into main
Reviewed-on: #2
Reviewed-by: StanPonomarev <dmss@krtss.de>
2026-07-21 22:03:45 +02:00
agentci 378e372a4b agent: Implemented the explicit persisted webhook state machine. 2026-07-21 14:13:58 +00:00
StanPonomarev 73045258fa feat: reuse single comment 2026-07-21 00:54:46 +02:00
StanPonomarev a9289e656c fix: logs and build 2026-07-21 00:46:16 +02:00
StanPonomarev 7a1b18f931 fix: opencode config 2 2026-07-21 00:42:56 +02:00
StanPonomarev 6b857e9adb fix: opencode config 2026-07-21 00:37:11 +02:00
StanPonomarev 45858bff06 fix: restore auth and research variant 2026-07-21 00:28:19 +02:00
StanPonomarev d3946b195e fix: opencode restarting 2026-07-21 00:16:04 +02:00
StanPonomarev b47430c963 fix: remove build script 2026-07-21 00:10:58 +02:00
StanPonomarev 6f24df8cd3 feat: switch to opencode 2026-07-21 00:00:24 +02:00
137 changed files with 10734 additions and 3116 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
AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:latest
AGENTCI_GITEA_URL=http://gitea:3000
AGENTCI_BOT_USERNAME=agentci
AGENTCI_BOT_NAME=Agent CI
AGENTCI_BOT_EMAIL=agentci@localhost
AGENTCI_PLAN_MODEL=gpt-5.6-sol
AGENTCI_PLAN_REASONING=medium
AGENTCI_IMPLEMENT_MODEL=gpt-5.6-sol
AGENTCI_IMPLEMENT_REASONING=high
AGENTCI_RESEARCH_MODEL=gpt-5.6-luna
AGENTCI_RESEARCH_REASONING=high
OPENCODE_SERVER_USERNAME=opencode
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
AGENTCI_PLAN_VARIANT=
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
AGENTCI_IMPLEMENT_VARIANT=
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.
AGENTCI_CONTEXT7_API_KEY=
AGENTCI_PLAN_REVIEW_ROUNDS=4
AGENTCI_IMPLEMENT_REVIEW_ROUNDS=3
AGENTCI_TURN_TIMEOUT_SECONDS=3600
AGENTCI_MAX_CONCURRENT_JOBS=2
# Comma-delimited built-in or custom script names, for example: python,dotnet,company-tools
AGENTCI_INSTALL_SCRIPTS=
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
AGENTCI_PYTHON_VERSION=3.13
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:
- main
env:
REGISTRY: git.krtss.de
IMAGE_NAME: git.krtss.de/stanponomarev/agentci
jobs:
publish:
name: Build and push
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
pull: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
labels: |
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
+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>
+133
View File
@@ -0,0 +1,133 @@
# Agent CI repository instructions
## Scope
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
recovery, deployment, or the security boundary.
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
the requested change merely for consistency.
## Repository map
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
task claiming, and the `JobRun` event interface.
- `src/agentci/application/`: runtime composition, durable control/job queues, task handlers,
recovery, and comment reconciliation.
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
contracts; keep these concerns outside Python orchestration.
- `src/agentci/migrations/`: ordered SQLite migrations.
- `tests/`: pytest suite, generally organized by module or behavior.
- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and
trusted runtime configuration.
## Setup and commands
Use Python 3.13 or newer and `uv`. Run commands from the repository root.
```sh
uv sync
```
Run the narrowest relevant test while iterating:
```sh
uv run pytest tests/test_<area>.py
uv run pytest tests/test_<area>.py::test_<behavior>
uv run pytest -k '<expression>'
```
Run the standard Python checks before completion:
```sh
uv run ruff check .
uv run pyright
uv run pytest
```
Coverage is diagnostic and has no required threshold:
```sh
uv run pytest --cov=agentci --cov-branch
```
For deployment-related changes, also run:
```sh
docker compose config
```
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
OpenCode configuration. It may require network access and takes longer than the normal checks.
## Engineering conventions
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
rather than mutating state in place.
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
events and task requests.
- Apply state transitions, event persistence, and resulting task creation atomically through
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
deterministic comment reconciliation.
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
failed and its sessions are aborted rather than replayed.
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
log credentials, secret contents, authorization headers, or private prompt data.
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
applied.
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
hand.
## Testing conventions
- Add regression tests for behavior changes, especially reducer transitions, persistence and
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
asynchronous.
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
error text rather than private implementation details.
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
or network access.
## Security and operational boundaries
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime data
directories, databases, cloned private repositories, or Docker volume contents. Do not send
secrets or private repository content to external search or research services.
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
secret-file loading.
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
or external skills, expose its server to the host, add privileged/capability settings, or add
writable host mounts beyond the documented `./data/agentci` and `./data/opencode` state directories
without an explicit security review.
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
credentials to them.
- Git pushes performed by Agent CI must remain non-forcing.
- Do not run provider authentication, start/restart deployment services, modify production data, or
perform other live operations unless the user explicitly requests it.
- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`,
`.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`.
## Completion expectations
- Run focused tests first, then all applicable standard checks. If a check cannot run, report the
exact command and reason.
- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands,
configuration, deployment, recovery behavior, or security assumptions.
- Summarize behavior changes, validation performed, and any migration, compatibility, or security
implications in the final response or pull-request description.
+33 -22
View File
@@ -1,12 +1,14 @@
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 AGENTCI_OPENCODE_VERSION=^1
RUN npm install --global \
"@openai/codex@${CODEX_VERSION}" \
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
"opencode-ai@${AGENTCI_OPENCODE_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
@@ -19,30 +21,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \
UV_NO_DEV=1 \
CODEGRAPH_TELEMETRY=0 \
CODEX_HOME=/var/lib/codex \
XDG_CONFIG_HOME=/run/agentci \
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN apt-get update \
&& 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 \
&& rm -rf /var/lib/apt/lists/* \
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \
&& chown agentci:agentci /run/agentci \
&& chmod u+s /usr/bin/bwrap
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \
/etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \
&& 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=tea /bin/tea /usr/local/bin/tea
COPY --from=codex /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=codex /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 \
&& 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 \
COPY --from=tea /bin/tea /usr/local/libexec/tea
COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node
COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode
COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
/usr/local/bin/codegraph
WORKDIR /opt/agentci
@@ -50,16 +49,28 @@ COPY pyproject.toml uv.lock README.md ./
COPY src ./src
COPY scripts ./scripts
COPY install-scripts /etc/agentci/install-scripts
COPY codex/config.toml /etc/codex/config.toml
COPY opencode /etc/opencode
RUN chmod 0755 \
/opt/agentci/scripts/entrypoint.sh \
/opt/agentci/scripts/gitea-askpass.sh \
/usr/local/bin/tea \
/usr/local/bin/codex \
/opt/agentci/scripts/tea.sh \
/usr/local/libexec/tea \
/usr/local/bin/opencode \
/usr/local/bin/codegraph \
&& ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \
&& 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
EXPOSE 8080
+149 -81
View File
@@ -1,8 +1,25 @@
# Agent CI
Agent CI is a private Gitea webhook host that turns issue and pull-request
comments into resumable Codex planning and implementation workflows. It runs as
one persistent Docker Compose service on the same Docker network as Gitea.
Agent CI is a private Gitea webhook host that turns issue and pull-request comments into resumable
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
private OpenCode server on the same Docker network as Gitea.
## Architecture
The service has one durable execution path:
```text
webhook -> repository -> reducer -> durable task -> worker -> workflow -> integration
```
`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions
atomically to SQLite and persists the resulting tasks. `application/worker/` executes those tasks
and passes an explicit `JobRun` into the functions under `workflows/`. Modules under
`integrations/` own external effects, while `api/` contains the FastAPI host and routes.
Jobs and workflows are immutable snapshots. Workflows return their final comment body directly;
progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table
is the authoritative state snapshot, while `job_events` provides durable idempotency and audit data.
## Commands
@@ -15,68 +32,121 @@ 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 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
command gets separate queued and started comments. Final plans, PR results,
failures, and remaining review findings are posted separately.
The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Commands
are durably sequenced when their webhook arrives, then authorized and executed in receive order per
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
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
Gitea URL.
3. Create `secrets/gitea_token` containing the bot token and
`secrets/webhook_secret` containing a high-entropy webhook secret.
4. Build and start the service:
2. Copy `.env.example` to `.env` and set the external network, Gitea URL, and provider-qualified
OpenCode models.
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
4. Create the writable state directories for the non-root container user:
```sh
docker compose up --build -d
mkdir -p data/agentci data/opencode
sudo chown -R 10001:10001 data/agentci data/opencode
```
5. Authenticate Codex interactively in the persistent container:
5. Log in to the Gitea container registry with a personal access token, then pull the image:
```sh
docker compose exec agentci codex login --device-auth
docker compose exec agentci codex login status
docker login git.krtss.de
docker compose pull
```
6. In Gitea, create a JSON webhook targeting
`http://agentci:8080/webhooks/gitea`. Set the same secret and subscribe to
issue comments, PR timeline comments, and PR review comments.
6. Authenticate the configured OpenCode providers before starting the persistent server:
`/health/live` reports process health. `/health/ready` returns 503 until Codex
authentication is usable. The worker leaves jobs queued while authentication is
missing.
```sh
docker compose run --rm opencode opencode auth login
docker compose run --rm opencode opencode auth list
```
## Configuration
7. Start the services:
Model, reasoning effort, review-pass counts, bot identity, branch prefix, and
turn timeout use `AGENTCI_` environment variables. Defaults are shown in
`.env.example`. Gitea credentials and webhook secrets are intentionally
file-based Compose secrets.
```sh
docker compose up --no-build -d
```
Every planning and implementation session can delegate external research to a
read-only `research` subagent. It defaults to `gpt-5.6-luna` with high reasoning
and has public network access, live web search, Context7 documentation lookup,
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.
8. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
Planning, implementation, and all review sessions also receive the local
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.
OpenCode caches provider state. After adding or changing authentication on an already running
deployment, run the one-off `auth login` command above and then `docker compose restart opencode`.
### Development environments
`/health/live` reports process health. `/health/ready` returns 503 until the OpenCode server is
healthy and every configured model exists, supports tool calls, accepts its configured variant, and
has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development
environment installers. The supplied `python` and `dotnet` scripts install only
their runtimes; they are ordinary scripts that can be replaced or removed.
Implementation agents remain responsible for restoring project dependencies
and selecting build/test commands. Configure the supplied scripts with
`AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
### Image publishing
Every push to `main` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both
`git.krtss.de/stanponomarev/agentci:latest` and
`git.krtss.de/stanponomarev/agentci:<full-commit-sha>`. Configure these repository Actions secrets
before the first run:
| Secret | Value |
| --- | --- |
| `REGISTRY_USERNAME` | Username that owns the package or can write packages for the owner. |
| `REGISTRY_TOKEN` | Personal access token with package write permission. |
The automatic Gitea Actions token cannot publish packages. Compose uses `latest` by default; set
`AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:<full-commit-sha>` in `.env` to deploy an immutable
revision.
## OpenCode
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. Persistent
state is exposed through writable host bind mounts at `./data/agentci` and `./data/opencode`; protect
these directories because they contain private repository clones, installed tools, provider state,
and resumable sessions. The OpenCode HTTP server is not published to the host, is password protected,
and is reachable by Agent CI over an internal Compose network.
## Development environments
`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
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
@@ -84,44 +154,38 @@ AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0
```
Every name resolves to an executable file in `install-scripts/`, mounted
read-only at `/etc/agentci/install-scripts`. Names cannot contain paths and
duplicates are rejected. See `install-scripts/README.md` for the script contract.
Every name resolves to a file under `/etc/agentci/install-scripts`, copied from `install-scripts/`
when the image is built. Rebuild the image after adding, replacing, or removing an installer. Names
cannot contain paths and duplicates are rejected. Installers 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
`./data/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
the job on an unknown script, timeout, or non-zero exit. They receive no AgentCI
or Gitea secret values in their environment, but remain trusted operator code
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.
Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
Git commands itself.
## State and recovery
The `agentci_data` volume contains SQLite, persistent workflow clones, and
installed development runtimes.
`codex_home` contains login state and resumable Codex sessions. Both are kept
indefinitely and should be backed up together.
The `data/agentci` directory contains SQLite, workflow clones, and installed development runtimes.
The `data/opencode` directory contains provider authentication, OpenCode's database, and resumable
sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
`data/opencode`. Back up both persistent directories together.
Queued jobs survive restart. An in-progress job is marked failed after restart
instead of being replayed, because replaying a partially completed model turn
could duplicate changes. Git pushes are never forced.
SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
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
@@ -132,7 +196,11 @@ uv sync
uv run ruff check .
uv run pyright
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
schemas live outside Python so orchestration modules remain small and readable.
The coverage command is an opt-in diagnostic report; the regular test run remains the default and
coverage percentage is not used as a pass threshold.
Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow.
-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 -34
View File
@@ -1,41 +1,29 @@
services:
agentci:
build:
context: .
args:
CODEX_VERSION: ${CODEX_VERSION:-0.144.6}
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
TEA_VERSION: ${TEA_VERSION:-0.14.2}
image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}
restart: unless-stopped
# Codex applies its own bwrap sandbox inside this otherwise unprivileged container.
cap_add:
- SYS_ADMIN
- 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
depends_on:
opencode:
condition: service_healthy
environment:
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci}
AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI}
AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost}
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-gpt-5.6-sol}
AGENTCI_PLAN_REASONING: ${AGENTCI_PLAN_REASONING:-medium}
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-gpt-5.6-sol}
AGENTCI_IMPLEMENT_REASONING: ${AGENTCI_IMPLEMENT_REASONING:-high}
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-gpt-5.6-luna}
AGENTCI_RESEARCH_REASONING: ${AGENTCI_RESEARCH_REASONING:-high}
AGENTCI_CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-}
AGENTCI_OPENCODE_URL: http://opencode:4096
AGENTCI_OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-openai/gpt-5.6-sol}
AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-}
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol}
AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-}
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_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
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_SCRIPTS: ${AGENTCI_INSTALL_SCRIPTS:-}
AGENTCI_PYTHON_VERSION: ${AGENTCI_PYTHON_VERSION:-3.13}
@@ -43,26 +31,76 @@ services:
secrets:
- gitea_token
- webhook_secret
- opencode_server_password
volumes:
- agentci_data:/var/lib/agentci
- codex_home:/var/lib/codex
- ./install-scripts:/etc/agentci/install-scripts:ro
- ./data/agentci:/var/lib/agentci
tmpfs:
- /run/agentci:mode=1777
expose:
- "8080"
networks:
- 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:
gitea_token:
file: ./secrets/gitea_token
webhook_secret:
file: ./secrets/webhook_secret
volumes:
agentci_data:
codex_home:
opencode_server_password:
file: ./secrets/opencode_server_password
networks:
gitea:
external: true
name: ${GITEA_NETWORK:-gitea}
agentci_control:
internal: true
+8 -9
View File
@@ -2,10 +2,10 @@
This directory supplies the ready-made `python` and `dotnet` scripts. They are not reserved:
modify, replace, or remove them like any other script. Place other trusted executable install
scripts here and add the desired file names to `AGENTCI_INSTALL_SCRIPTS`. Compose mounts the
directory read-only at `/etc/agentci/install-scripts`. Executable files run directly; files without
executable mode run as POSIX shell scripts through `/bin/sh` so bind mounts do not depend on host
file-mode preservation.
scripts here and add the desired file names to `AGENTCI_INSTALL_SCRIPTS`. The Docker build copies
this directory to `/etc/agentci/install-scripts`, so rebuild the image after changing its contents.
Executable files run directly; files without executable mode run as POSIX shell scripts through
`/bin/sh`.
Scripts run from the cloned repository with a sanitized environment. They receive:
@@ -13,15 +13,14 @@ Scripts run from the cloned repository with a sanitized environment. They receiv
- `PATH`: `$DEV_TOOLS_DIR/bin` followed by the service path
- `PYTHON_VERSION` and `DOTNET_CHANNEL`: configured built-in runtime versions
`DEV_TOOLS_DIR` is shared by jobs through the `agentci_data` volume. Put downloaded SDK/runtime
`DEV_TOOLS_DIR` is shared by jobs through the `data/agentci` bind mount. Put downloaded SDK/runtime
files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/bin`; that `bin`
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
not persist into implementation turns.
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable
.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a
normal user home and receive read-only access to installed tools, so runtime caches cannot live
beside the SDK.
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state
and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only
so it cannot be used to persist global agent configuration.
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
+4 -3
View File
@@ -6,6 +6,7 @@ set -eu
install_dir="$DEV_TOOLS_DIR/dotnet"
bin_dir="$DEV_TOOLS_DIR/bin"
runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet"
installer=$(mktemp)
trap 'rm -f "$installer"' EXIT
@@ -25,9 +26,9 @@ PYTHON
printf '%s\n' \
'#!/bin/sh' \
"export DOTNET_ROOT='$install_dir'" \
'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \
'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \
'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \
"export DOTNET_CLI_HOME='$runtime_dir/home'" \
"export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \
"export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \
'export HOME="$DOTNET_CLI_HOME"' \
'export DOTNET_CLI_TELEMETRY_OPTOUT=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]
name = "agentci"
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"
requires-python = ">=3.13"
dependencies = [
@@ -20,7 +20,7 @@ dev = [
"pyright>=1.1.403",
"pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2",
"respx>=0.22,<1",
"pytest-cov>=6,<8",
"ruff>=0.12,<1",
]
+9 -2
View File
@@ -1,12 +1,18 @@
#!/bin/sh
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 os
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_path = config_dir / "config.yml"
token_path = Path(
@@ -22,5 +28,6 @@ login = {
config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
config_path.chmod(0o600)
'
fi
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."""
__version__ = "0.1.0"
"""Gitea-triggered OpenCode workflow host."""
+2 -3
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
import uvicorn
from agentci.app import create_app
from agentci.config import Settings
from agentci.api.app import create_app
from agentci.config.settings import Settings
def main() -> None:
@@ -19,4 +19,3 @@ def main() -> None:
if __name__ == "__main__":
main()
-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 typing import Annotated
from pydantic import Field, SecretStr, field_validator
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
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"
port: int = 8080
data_dir: Path = Path("/var/lib/agentci")
codex_home: Path = Path("/var/lib/codex")
gitea_url: str = "http://gitea:3000"
gitea_token_file: Path = Path("/run/secrets/gitea_token")
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_name: str = "Agent CI"
bot_email: str = "agentci@localhost"
branch_prefix: str = "agent"
askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh")
plan_model: str = "gpt-5.6-sol"
plan_reasoning: str = "medium"
implement_model: str = "gpt-5.6-sol"
implement_reasoning: str = "high"
research_model: str = "gpt-5.6-luna"
research_reasoning: str = "high"
context7_api_key: SecretStr | None = None
plan_model: str = "openai/gpt-5.6-sol"
plan_variant: str | None = None
implement_model: str = "openai/gpt-5.6-sol"
implement_variant: str | None = None
explore_model: str = "openai/gpt-5.6-luna"
explore_variant: str = "low"
research_model: str = "openai/gpt-5.6-luna"
research_variant: str = "high"
plan_review_rounds: int = Field(default=4, ge=1, le=20)
implement_review_rounds: int = Field(default=3, ge=1, le=20)
turn_timeout_seconds: int = Field(default=3600, ge=60)
install_script_timeout_seconds: int = Field(default=900, ge=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_dir: Path = Path("/etc/agentci/install-scripts")
python_version: str = "3.13"
dotnet_channel: str = "10.0"
@field_validator("gitea_url")
@field_validator("gitea_url", "opencode_url")
@classmethod
def strip_url(cls, value: str) -> str:
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")
@classmethod
def parse_install_scripts(cls, value: object) -> list[str]:
@@ -77,6 +88,12 @@ class Settings(BaseSettings):
def webhook_secret(self) -> bytes:
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
def database_path(self) -> Path:
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
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]*))?$")
+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 pathlib import Path
from pydantic import BaseModel, Field
class CommandName(StrEnum):
PLAN = "plan"
@@ -25,6 +23,7 @@ class JobKind(StrEnum):
class JobStatus(StrEnum):
RECEIVED = "received"
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
@@ -43,43 +42,17 @@ class WorkflowStatus(StrEnum):
FAILED = "failed"
class ReviewSeverity(StrEnum):
BLOCKING = "blocking"
MAJOR = "major"
MINOR = "minor"
class TaskKind(StrEnum):
AUTHORIZE = "authorize"
EXECUTE = "execute"
RECONCILE_COMMENT = "reconcile_comment"
FAIL_WORKFLOW = "fail_workflow"
ABORT_SESSIONS = "abort_sessions"
class PlanArtifact(BaseModel):
plan_markdown: str = Field(min_length=1)
class DiscussionReply(BaseModel):
markdown: str = Field(min_length=1)
class AgentResult(BaseModel):
summary_markdown: str = Field(min_length=1)
tests: list[str] = Field(default_factory=list)
class ReviewFinding(BaseModel):
severity: ReviewSeverity
title: str
detail: str
location: str | None = None
recommendation: str
class ReviewReport(BaseModel):
summary: str
findings: list[ReviewFinding] = Field(default_factory=list)
@property
def has_serious_findings(self) -> bool:
return any(
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
for finding in self.findings
)
class QueueName(StrEnum):
CONTROL = "control"
JOBS = "jobs"
@dataclass(frozen=True)
@@ -89,7 +62,7 @@ class ParsedCommand:
@dataclass(frozen=True)
class CommandEvent:
class IncomingCommand:
delivery_id: str
comment_id: int
repo_owner: str
@@ -109,25 +82,35 @@ class CommandEvent:
return f"{self.repo_owner}/{self.repo_name}:{target}"
@dataclass
@dataclass(frozen=True)
class Job:
id: str
kind: JobKind
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
message: str
comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None
status: JobStatus = JobStatus.QUEUED
stage: str = "queued"
runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass
@dataclass(frozen=True)
class Workflow:
id: str
kind: WorkflowKind
@@ -136,6 +119,7 @@ class Workflow:
issue_number: int
workspace_path: Path
base_sha: str
runtime: str = "opencode"
branch: str | None = None
pr_number: int | None = None
primary_session_id: str | None = None
@@ -143,3 +127,19 @@ class Workflow:
artifact: str | None = None
review_json: str | None = None
status: WorkflowStatus = WorkflowStatus.ACTIVE
@dataclass(frozen=True)
class TaskRequest:
kind: TaskKind
queue: QueueName
@dataclass(frozen=True)
class Task:
id: int
job_id: str
source_event_id: str
kind: TaskKind
queue: QueueName
attempts: int
+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
class CodeGraphClient:
class CodeGraph:
async def prepare(self, workspace: Path) -> None:
self._exclude_index(workspace)
index = workspace / ".codegraph" / "codegraph.db"
@@ -32,6 +32,7 @@ class DevelopmentEnvironment:
self.timeout_seconds = timeout_seconds
self.python_version = python_version
self.dotnet_channel = dotnet_channel
self._prepare_lock = asyncio.Lock()
@property
def description(self) -> str:
@@ -40,6 +41,7 @@ class DevelopmentEnvironment:
async def prepare(self, workspace: Path) -> None:
if not self.scripts:
return
async with self._prepare_lock:
self.tools_dir.mkdir(parents=True, exist_ok=True)
(self.tools_dir / "bin").mkdir(exist_ok=True)
for name in self.scripts:
@@ -13,7 +13,7 @@ class GitError(RuntimeError):
pass
class GitClient:
class Git:
def __init__(
self,
*,
@@ -31,9 +31,6 @@ class GitClient:
self.commit_name = commit_name
self.commit_email = commit_email
def clone_url(self, owner: str, repo: str) -> str:
return f"{self.gitea_url}/{owner}/{repo}.git"
async def clone(
self,
owner: str,
@@ -47,7 +44,7 @@ class GitClient:
"--branch",
branch,
"--single-branch",
self.clone_url(owner, repo),
f"{self.gitea_url}/{owner}/{repo}.git",
str(destination),
cwd=destination.parent,
authenticated=True,
@@ -85,6 +82,7 @@ class GitClient:
"-m",
message,
cwd=workspace,
command_name="commit",
)
return await self.current_sha(workspace)
@@ -101,8 +99,10 @@ class GitClient:
*args: str,
cwd: Path,
authenticated: bool = False,
command_name: str | None = None,
) -> str:
operation = f"git.{args[0]}"
command_name = command_name or args[0]
operation = f"git.{command_name}"
started = monotonic()
log.info("git step started", extra={"operation": operation})
environment = os.environ.copy()
@@ -130,14 +130,14 @@ class GitClient:
"git step could not start",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise GitError(f"Could not run git {args[0]}: {exc}") from exc
raise GitError(f"Could not run git {command_name}: {exc}") from exc
if process.returncode:
detail = stderr.decode(errors="replace").strip()
log.error(
"git step failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise GitError(f"git {args[0]} failed: {detail[-1000:]}")
raise GitError(f"git {command_name} failed: {detail[-1000:]}")
log.info(
"git step completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
@@ -0,0 +1 @@
"""Gitea API and webhook integration."""
@@ -7,12 +7,7 @@ from typing import Any
import httpx
from agentci.adapters.gitea_models import (
CommentInfo,
IssueInfo,
PullRequestInfo,
RepositoryInfo,
)
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
log = logging.getLogger(__name__)
@@ -21,27 +16,37 @@ class GiteaError(RuntimeError):
pass
class GiteaClient:
def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None:
class Gitea:
def __init__(
self,
base_url: str,
token: str,
*,
retries: int = 3,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.retries = retries
self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
timeout=30,
transport=transport,
)
async def close(self) -> None:
await self.client.aclose()
async def repository(self, owner: str, repo: str) -> RepositoryInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return RepositoryInfo(
owner=owner,
name=repo,
full_name=data.get("full_name", f"{owner}/{repo}"),
default_branch=data["default_branch"],
async def has_write_permission(self, owner: str, repo: str, username: str) -> bool:
response = await self._request(
"GET", f"/repos/{owner}/{repo}/collaborators/{username}/permission"
)
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:
data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json()
@@ -98,6 +103,15 @@ class GiteaClient:
)
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(
self,
owner: str,
@@ -3,14 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RepositoryInfo:
owner: str
name: str
full_name: str
default_branch: str
@dataclass(frozen=True)
class IssueInfo:
number: int
@@ -43,4 +35,3 @@ class PullRequestInfo:
@property
def is_open(self) -> bool:
return self.state == "open" and not self.merged
@@ -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",
"path",
"status_code",
"error_message",
"attempt",
"item_count",
"duration_ms",
@@ -37,9 +38,14 @@ class JsonFormatter(logging.Formatter):
try:
return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
payload["message"] = "Log record could not be serialized"
payload["exception"] = traceback.format_exc()
return json.dumps(payload, ensure_ascii=False)
fallback = {
"timestamp": payload["timestamp"],
"level": payload["level"],
"logger": payload["logger"],
"message": "Log record could not be serialized",
"exception": traceback.format_exc(),
}
return json.dumps(fallback, ensure_ascii=False)
def configure_logging() -> None:
@@ -49,3 +55,5 @@ def configure_logging() -> None:
root.handlers.clear()
root.addHandler(handler)
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
from pathlib import Path
from string import Template
class PromptLibrary:
def __init__(self, directory: Path | None = None) -> None:
self.directory = directory or Path(__file__).parent
def render(self, name: str, **values: str) -> str:
template = Template((self.directory / f"{name}.md").read_text())
return template.substitute(values)
"""Prompt templates and structured-output contracts."""
+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
import asyncio
from typing import Any
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo
from agentci.adapters.storage import Storage
from agentci.engine.repository import Repository
from agentci.integrations.gitea.client import Gitea
from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo
class ContextBuilder:
def __init__(self, gitea: GiteaClient, storage: Storage) -> None:
self.gitea = gitea
self.storage = storage
async def issue_context(self, owner: str, repo: str, number: int) -> str:
issue = await self.gitea.issue(owner, repo, number)
comments = await self.gitea.issue_comments(owner, repo, number)
operational = await self.storage.operational_comment_ids(owner, repo, number)
async def build_issue_context(
gitea: Gitea,
repository: Repository,
owner: str,
repo: str,
number: int,
) -> str:
issue, comments, operational = await asyncio.gather(
gitea.issue(owner, repo, number),
gitea.issue_comments(owner, repo, number),
repository.operational_comment_ids(owner, repo, number),
)
discussion = "\n\n".join(
_format_comment(comment) for comment in comments if comment.id not in operational
)
return (
f"Repository: {owner}/{repo}\n"
f"Issue: #{number} {issue.title}\n"
f"Issue: #{number} \u2014 {issue.title}\n"
f"State: {issue.state}\n\n"
f"## Issue body\n{issue.body or '(empty)'}\n\n"
f"## Discussion\n{discussion or '(none)'}"
)
async def pull_request_context(
self, owner: str, repo: str, number: int
) -> tuple[PullRequestInfo, str]:
pull = await self.gitea.pull_request(owner, repo, number)
timeline = await self.gitea.issue_comments(owner, repo, number)
reviews = await self.gitea.pull_reviews(owner, repo, number)
commits = await self.gitea.pull_commits(owner, repo, number)
review_text = await self._format_reviews(owner, repo, number, reviews)
async def build_pull_request_context(
gitea: Gitea,
owner: str,
repo: str,
number: int,
) -> tuple[PullRequestInfo, str]:
pull, timeline, reviews, commits = await asyncio.gather(
gitea.pull_request(owner, repo, number),
gitea.issue_comments(owner, repo, number),
gitea.pull_reviews(owner, repo, number),
gitea.pull_commits(owner, repo, number),
)
review_text = await _format_reviews(gitea, owner, repo, number, reviews)
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
commit_text = "\n".join(
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
@@ -42,7 +52,7 @@ class ContextBuilder:
)
context = (
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"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
f"{pull.head_branch} @ {pull.head_sha}\n\n"
@@ -53,21 +63,35 @@ class ContextBuilder:
)
return pull, context
async def _format_reviews(
self,
async def _format_reviews(
gitea: Gitea,
owner: str,
repo: str,
number: int,
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] = []
for review in reviews:
review_id = int(review["id"])
author = review.get("user", {}).get("login", "unknown")
state = review.get("state", "unknown")
body = review.get("body") or "(empty)"
for (review_id, author, state, body), comments in zip(
details, comment_groups, strict=True
):
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
for comment in await self.gitea.review_comments(owner, repo, number, review_id):
for comment in comments:
path = comment.get("path") or "unknown file"
line = comment.get("new_position") or comment.get("old_position") or "?"
text = comment.get("body") or ""
@@ -78,4 +102,3 @@ class ContextBuilder:
def _format_comment(comment: CommentInfo) -> str:
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 dataclasses import replace
from uuid import uuid4
from agentci.domain.models import (
DiscussionReply,
from agentci.engine.model import (
Job,
PlanArtifact,
ReviewReport,
Workflow,
WorkflowKind,
WorkflowStatus,
)
from agentci.workflows.common import (
Dependencies,
from agentci.engine.run import JobRun
from agentci.workflows.context import build_issue_context
from agentci.workflows.model import DiscussionReply, PlanArtifact
from agentci.workflows.render import (
JobRejected,
agent_comment,
final_comment,
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:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
async def plan(self, job: Job) -> None:
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
async def create_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
workflow_id = str(uuid4())
workspace = 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(
workspace = services.settings.workspaces_dir / workflow_id / "repo"
await run.stage("cloning")
base_sha = await services.git.clone(
job.repo_owner,
job.repo_name,
repository.default_branch,
default_branch,
workspace,
)
workflow = Workflow(
@@ -45,193 +43,143 @@ class PlanWorkflow:
workspace_path=workspace,
base_sha=base_sha,
)
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="planning")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
await run.create_workflow(workflow, "planning")
context = await build_issue_context(
services.gitea,
services.repository,
job.repo_owner,
job.repo_name,
job.issue_number,
)
prompt = self.deps.prompts.render(
prompt = services.prompts.render(
"plan_initial",
context=context,
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,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
model=services.settings.plan_model,
variant=services.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow.primary_session_id = session_id
workflow.artifact = artifact.plan_markdown
await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
workflow = replace(workflow, artifact=artifact.plan_markdown)
await services.repository.save_workflow(workflow)
async def discuss(self, job: Job) -> None:
workflow = await self._latest_plan(job)
workflow, artifact, report = await review_plan_loop(
workflow, context, artifact, run, services
)
workflow = replace(
workflow,
artifact=artifact.plan_markdown,
review_json=report.model_dump_json(),
status=WorkflowStatus.COMPLETED,
)
await services.repository.save_workflow(workflow)
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
async def discuss_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
workflow = await _latest_plan(job, services)
if workflow.runtime != "opencode":
raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; "
"start a new `/agent plan`."
)
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing")
prompt = self.deps.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message
await run.link_workflow(workflow.id, "discussing")
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,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
await self.deps.gitea.create_comment(
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("discussion", workflow.id, reply.markdown),
)
return agent_comment("discussion", workflow.id, reply.markdown)
async def iterate(self, job: Job) -> None:
await self._reject_if_active_or_merged_pr(job)
workflow = await self._latest_plan(job)
async def iterate_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
await _reject_if_active_or_merged_pr(job, services)
workflow = await _latest_plan(job, services)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
raise JobRejected("The latest plan has no saved artifact.")
await 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(
job.repo_owner, job.repo_name, job.issue_number
)
prompt = self.deps.prompts.render(
prompt = services.prompts.render(
"plan_iterate",
context=context,
artifact=workflow.artifact,
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await self.deps.codex.resume(
artifact = await services.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
model=services.settings.plan_model,
variant=services.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
)
report = await self._review(workflow, context, artifact)
await self._finish(job, workflow, artifact, report)
async def _review_loop(
self, job: Job, workflow: Workflow, context: str, artifact: PlanArtifact
) -> ReviewReport:
report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.plan_review_rounds):
await 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",
workflow, report = await review_plan_once(workflow, context, artifact, services)
workflow = replace(
workflow,
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(
session_id=_required(workflow.primary_session_id),
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
await services.repository.save_workflow(workflow)
return final_comment("plan", workflow.id, artifact.plan_markdown, 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(
self,
job: Job,
workflow: Workflow,
artifact: PlanArtifact,
report: ReviewReport,
) -> None:
workflow.artifact = artifact.plan_markdown
workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow)
await self.deps.gitea.create_comment(
async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow:
workflow = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("plan", workflow.id, artifact.plan_markdown),
)
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
WorkflowKind.PLAN,
)
if workflow is None:
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
return workflow
async def _reject_if_active_or_merged_pr(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
async def _reject_if_active_or_merged_pr(
job: Job, services: WorkflowServices
) -> None:
workflows = await services.repository.implementation_workflows(
job.repo_owner, job.repo_name, job.issue_number
)
for workflow in workflows:
if workflow.pr_number is None:
continue
pull = await self.deps.gitea.pull_request(
pull = await services.gitea.pull_request(
job.repo_owner, job.repo_name, workflow.pr_number
)
if pull.is_open or pull.merged:
@@ -239,9 +187,3 @@ class PlanWorkflow:
f"Issue plan iteration is disabled because agent PR #{pull.number} "
"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 agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus
from agentci.workflows.change_set import ChangeSet, result_comment
from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import (
Dependencies,
from dataclasses import replace
from agentci.engine.model import Job, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun
from agentci.workflows.context import (
build_issue_context,
build_pull_request_context,
)
from agentci.workflows.implementation import commit_and_push
from agentci.workflows.model import AgentResult
from agentci.workflows.render import (
JobRejected,
agent_comment,
final_comment,
report_for_prompt,
report_json,
review_markdown,
result_comment,
)
from agentci.workflows.review import review_implementation_once
from agentci.workflows.services import WorkflowServices
class PullRequestWorkflow:
def __init__(self, dependencies: Dependencies) -> None:
self.deps = dependencies
self.review = CodeReviewLoop(dependencies)
self.changes = ChangeSet(dependencies)
async def iterate(self, job: Job) -> None:
async def iterate_implementation(
job: Job, run: JobRun, services: WorkflowServices
) -> str:
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
)
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED:
raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
pull, context = await self.deps.context.pull_request_context(
job.repo_owner, job.repo_name, pull_number
pull, context = await build_pull_request_context(
services.gitea, job.repo_owner, job.repo_name, pull_number
)
if not pull.is_open:
raise JobRejected("Implementation iteration requires an open pull request.")
if workflow.branch != pull.head_branch:
raise JobRejected("The pull request head branch no longer matches its workflow.")
await self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="synchronizing branch"
)
job.workflow_id = workflow.id
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch)
await self.deps.storage.update_job(
job.id, stage="installing development environment"
)
await self.deps.development.prepare(workflow.workspace_path)
await self.deps.storage.update_job(job.id, stage="implementing iteration")
prompt = self.deps.prompts.render(
await run.link_workflow(workflow.id, "synchronizing branch")
await services.git.sync_branch(workflow.workspace_path, pull.head_branch)
await run.stage("installing development environment")
await services.development.prepare(workflow.workspace_path)
await run.stage("implementing iteration")
prompt = services.prompts.render(
"implementation_iterate",
context=context,
review=report_for_prompt(workflow.review_json),
message=job.message or "(perform one additional reviewed refinement)",
development_environment=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,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
model=services.settings.implement_model,
variant=services.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
)
issue_context = await 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(
job.repo_owner, job.repo_name, workflow.issue_number, WorkflowKind.PLAN
plan = await services.repository.latest_workflow(
job.repo_owner,
job.repo_name,
workflow.issue_number,
WorkflowKind.PLAN,
)
report = await self.review.once(
workflow, report = await review_implementation_once(
workflow,
issue_context=issue_context,
plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
pull_context=context,
services=services,
)
sha = await self.changes.commit_and_push(
job,
sha = await commit_and_push(
run,
services,
workflow.workspace_path,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent iterate",
)
workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow)
await self.deps.gitea.create_comment(
job.repo_owner,
job.repo_name,
pull_number,
agent_comment("iteration", workflow.id, result_comment(result, sha=sha)),
workflow = replace(
workflow,
artifact=result.model_dump_json(),
review_json=report.model_dump_json(),
)
remaining = review_markdown(report)
if remaining:
await self.deps.gitea.create_comment(
job.repo_owner, job.repo_name, pull_number, remaining
await services.repository.save_workflow(workflow)
return final_comment(
"iteration", workflow.id, result_comment(result, sha=sha), report
)
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, 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:
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")
await self.deps.git.clone(
workspace = services.settings.workspaces_dir / f"fix-{job.id}" / "repo"
await run.stage("cloning pull request")
await services.git.clone(
pull.head_owner,
pull.head_repo,
pull.head_branch,
workspace,
)
await self.deps.storage.update_job(
job.id, stage="installing development environment"
)
await self.deps.development.prepare(workspace)
prompt = self.deps.prompts.render(
await run.stage("installing development environment")
await services.development.prepare(workspace)
prompt = services.prompts.render(
"fix",
context=context,
message=job.message or "(address the pull request feedback)",
development_environment=self.deps.development.description,
development_environment=services.development.description,
)
await self.deps.storage.update_job(job.id, stage="fixing")
_, result = await self.deps.codex.start(
await run.stage("fixing")
session_id = await services.opencode.create_session(workspace, "fix")
await run.link_session(session_id)
result = await services.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
model=services.settings.implement_model,
variant=services.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
sha = await self.changes.commit_and_push(
job,
sha = await commit_and_push(
run,
services,
workspace,
pull.head_branch,
result,
set_upstream=False,
commit_prefix="agent fix",
)
await self.deps.gitea.create_comment(
job.repo_owner,
job.repo_name,
pull_number,
agent_comment("fix", job.id, result_comment(result, sha=sha)),
)
return agent_comment("fix", job.id, result_comment(result, sha=sha))
def _pull_number(job: Job) -> int:
+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
View File
+26
View File
@@ -0,0 +1,26 @@
from dataclasses import dataclass
from pathlib import Path
import pytest
from agentci.engine import _sqlite
from agentci.engine.repository import Repository
@pytest.fixture
async def engine_repository(tmp_path: Path) -> Repository:
repository = Repository(tmp_path / "state.sqlite3")
await repository.initialize()
return repository
@dataclass
class SQLiteClock:
now: str = "2026-02-01T00:00:00+00:00"
@pytest.fixture
def sqlite_clock(monkeypatch: pytest.MonkeyPatch) -> SQLiteClock:
clock = SQLiteClock()
monkeypatch.setattr(_sqlite, "now", lambda: clock.now)
return clock
+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."}

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