Compare commits
19
Commits
60cb143402
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9f1e3d20 | ||
|
|
5ef10d28fe | ||
|
|
73243c1191 | ||
|
|
fb11e1f181 | ||
|
|
d6a0010632 | ||
|
|
4de8c2624a | ||
|
|
0526406472 | ||
|
|
98ac4abca1 | ||
|
|
7527831af6 | ||
|
|
18f364b069 | ||
|
|
378e372a4b | ||
|
|
73045258fa | ||
|
|
a9289e656c | ||
|
|
7a1b18f931 | ||
|
|
6b857e9adb | ||
|
|
45858bff06 | ||
|
|
d3946b195e | ||
|
|
b47430c963 | ||
|
|
6f24df8cd3 |
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Publish container image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: git.krtss.de
|
||||
IMAGE_NAME: git.krtss.de/stanponomarev/agentci
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
pull: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="~/repos/agentci/.venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/agentci.iml" filepath="$PROJECT_DIR$/.idea/agentci.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PyToolsState">
|
||||
<option name="tools">
|
||||
<map>
|
||||
<entry key="pyright">
|
||||
<value>
|
||||
<ToolEntry>
|
||||
<option name="enabled" value="true" />
|
||||
</ToolEntry>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="ruff">
|
||||
<value>
|
||||
<ToolEntry>
|
||||
<option name="enabled" value="true" />
|
||||
</ToolEntry>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,133 @@
|
||||
# Agent CI repository instructions
|
||||
|
||||
## Scope
|
||||
|
||||
- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides
|
||||
instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance.
|
||||
- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into
|
||||
durable OpenCode workflows. Read `README.md` before changing command behavior, persistence,
|
||||
recovery, deployment, or the security boundary.
|
||||
- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside
|
||||
the requested change merely for consistency.
|
||||
|
||||
## Repository map
|
||||
|
||||
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
|
||||
task claiming, and the `JobRun` event interface.
|
||||
- `src/agentci/application/`: runtime composition, durable control/job queues, task handlers,
|
||||
recovery, and comment reconciliation.
|
||||
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
|
||||
- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
|
||||
- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
|
||||
- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
|
||||
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
|
||||
contracts; keep these concerns outside Python orchestration.
|
||||
- `src/agentci/migrations/`: ordered SQLite migrations.
|
||||
- `tests/`: pytest suite, generally organized by module or behavior.
|
||||
- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and
|
||||
trusted runtime configuration.
|
||||
|
||||
## Setup and commands
|
||||
|
||||
Use Python 3.13 or newer and `uv`. Run commands from the repository root.
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
```
|
||||
|
||||
Run the narrowest relevant test while iterating:
|
||||
|
||||
```sh
|
||||
uv run pytest tests/test_<area>.py
|
||||
uv run pytest tests/test_<area>.py::test_<behavior>
|
||||
uv run pytest -k '<expression>'
|
||||
```
|
||||
|
||||
Run the standard Python checks before completion:
|
||||
|
||||
```sh
|
||||
uv run ruff check .
|
||||
uv run pyright
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Coverage is diagnostic and has no required threshold:
|
||||
|
||||
```sh
|
||||
uv run pytest --cov=agentci --cov-branch
|
||||
```
|
||||
|
||||
For deployment-related changes, also run:
|
||||
|
||||
```sh
|
||||
docker compose config
|
||||
```
|
||||
|
||||
Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or
|
||||
OpenCode configuration. It may require network access and takes longer than the normal checks.
|
||||
|
||||
## Engineering conventions
|
||||
|
||||
- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings
|
||||
in `pyproject.toml`. Use type annotations and existing modern Python patterns.
|
||||
- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values
|
||||
rather than mutating state in place.
|
||||
- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as
|
||||
events and task requests.
|
||||
- Apply state transitions, event persistence, and resulting task creation atomically through
|
||||
`Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata.
|
||||
- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow
|
||||
dependencies explicitly through `WorkflowServices` and progress through `JobRun`.
|
||||
- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and
|
||||
deterministic comment reconciliation.
|
||||
- Preserve restart semantics: queued work may resume, but an active partially executed model turn is
|
||||
failed and its sessions are aborted rather than replayed.
|
||||
- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never
|
||||
log credentials, secret contents, authorization headers, or private prompt data.
|
||||
- Add a new numbered migration for schema changes. Never edit a migration that may already have been
|
||||
applied.
|
||||
- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract.
|
||||
- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by
|
||||
hand.
|
||||
|
||||
## Testing conventions
|
||||
|
||||
- Add regression tests for behavior changes, especially reducer transitions, persistence and
|
||||
idempotency, restart recovery, queue ordering, webhook security, and integration error handling.
|
||||
- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for
|
||||
filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services.
|
||||
- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test
|
||||
asynchronous.
|
||||
- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe
|
||||
error text rather than private implementation details.
|
||||
- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker,
|
||||
or network access.
|
||||
|
||||
## Security and operational boundaries
|
||||
|
||||
- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime data
|
||||
directories, databases, cloned private repositories, or Docker volume contents. Do not send
|
||||
secrets or private repository content to external search or research services.
|
||||
- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and
|
||||
secret-file loading.
|
||||
- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration
|
||||
or external skills, expose its server to the host, add privileged/capability settings, or add
|
||||
writable host mounts beyond the documented `./data/agentci` and `./data/opencode` state directories
|
||||
without an explicit security review.
|
||||
- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with
|
||||
the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea
|
||||
credentials to them.
|
||||
- Git pushes performed by Agent CI must remain non-forcing.
|
||||
- Do not run provider authentication, start/restart deployment services, modify production data, or
|
||||
perform other live operations unless the user explicitly requests it.
|
||||
- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`,
|
||||
`.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`.
|
||||
|
||||
## Completion expectations
|
||||
|
||||
- Run focused tests first, then all applicable standard checks. If a check cannot run, report the
|
||||
exact command and reason.
|
||||
- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands,
|
||||
configuration, deployment, recovery behavior, or security assumptions.
|
||||
- Summarize behavior changes, validation performed, and any migration, compatibility, or security
|
||||
implications in the final response or pull-request description.
|
||||
+33
-22
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 +1 @@
|
||||
"""Gitea-triggered Codex workflow host."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
"""Gitea-triggered OpenCode workflow host."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""External-system adapters."""
|
||||
|
||||
@@ -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)
|
||||
@@ -1,75 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, database_path: Path, migrations_dir: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self.migrations_dir = migrations_dir
|
||||
|
||||
async def initialize(self) -> None:
|
||||
log.info("database initialization started", extra={"operation": "database.initialize"})
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
await self._run(self._initialize_sync)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"database initialization failed", extra={"operation": "database.initialize"}
|
||||
)
|
||||
raise
|
||||
log.info("database initialization completed", extra={"operation": "database.initialize"})
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.database_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
def _initialize_sync(self, connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations "
|
||||
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
|
||||
)
|
||||
applied = {
|
||||
row[0] for row in connection.execute("SELECT version FROM schema_migrations")
|
||||
}
|
||||
for path in sorted(self.migrations_dir.glob("*.sql")):
|
||||
version = int(path.name.split("_", 1)[0])
|
||||
if version in applied:
|
||||
continue
|
||||
connection.executescript(path.read_text())
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
|
||||
(version, now()),
|
||||
)
|
||||
|
||||
async def _update(self, table: str, row_id: str, updates: dict[str, object]) -> None:
|
||||
if not updates:
|
||||
return
|
||||
columns = ", ".join(f"{column}=?" for column in updates)
|
||||
values = [*updates.values(), row_id]
|
||||
await self._run(
|
||||
lambda connection: connection.execute(
|
||||
f"UPDATE {table} SET {columns} WHERE id=?", values
|
||||
)
|
||||
)
|
||||
|
||||
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
|
||||
# Operations are deliberately tiny and serialized by the single worker.
|
||||
# Avoid a thread pool so SQLite transactions retain deterministic ordering.
|
||||
with self._connect() as connection:
|
||||
return operation(connection)
|
||||
@@ -1,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"],
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
from agentci.adapters.job_store import JobStore
|
||||
from agentci.adapters.workflow_store import WorkflowStore
|
||||
|
||||
|
||||
class Storage(JobStore, WorkflowStore):
|
||||
"""Combined durable job and workflow repository."""
|
||||
|
||||
@@ -1,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 +1 @@
|
||||
"""HTTP API."""
|
||||
|
||||
"""HTTP application and routes."""
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agentci.api.errors import register_error_handlers
|
||||
from agentci.api.lifespan import create_lifespan
|
||||
from agentci.api.routes.health import router as health_router
|
||||
from agentci.api.routes.webhook import router as webhook_router
|
||||
from agentci.config.settings import Settings
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
selected_settings = settings or Settings()
|
||||
app = FastAPI(
|
||||
title="Agent CI",
|
||||
version="0.1.0",
|
||||
lifespan=create_lifespan(selected_settings),
|
||||
)
|
||||
app.include_router(health_router)
|
||||
app.include_router(webhook_router)
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from agentci.application.runtime import Runtime
|
||||
|
||||
|
||||
def get_runtime(request: Request) -> Runtime:
|
||||
return request.app.state.runtime
|
||||
|
||||
|
||||
type RuntimeDependency = Annotated[Runtime, Depends(get_runtime)]
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
||||
log.exception(
|
||||
"unhandled HTTP request failure",
|
||||
extra={
|
||||
"operation": "http.request",
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"error_message": str(exc),
|
||||
"status_code": 500,
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error. See service logs for diagnostics."},
|
||||
)
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
app.exception_handler(Exception)(unhandled_error)
|
||||
@@ -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"}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agentci.application.runtime import build_runtime
|
||||
from agentci.config.settings import Settings
|
||||
from agentci.observability.logging import configure_logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
|
||||
|
||||
|
||||
def create_lifespan(settings: Settings) -> Lifespan:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||
configure_logging()
|
||||
log.info("service startup started", extra={"operation": "service.startup"})
|
||||
try:
|
||||
runtime = await build_runtime(settings)
|
||||
except Exception:
|
||||
log.exception("service startup failed", extra={"operation": "service.startup"})
|
||||
raise
|
||||
app.state.runtime = runtime
|
||||
stop = asyncio.Event()
|
||||
worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
|
||||
try:
|
||||
log.info("service startup completed", extra={"operation": "service.startup"})
|
||||
yield
|
||||
finally:
|
||||
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
||||
stop.set()
|
||||
worker_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker_task
|
||||
finally:
|
||||
await runtime.close()
|
||||
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
||||
|
||||
return lifespan
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI route modules."""
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Application composition and durable orchestration."""
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agentci.application.worker.runner import Worker
|
||||
from agentci.config.settings import Settings
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.development import DevelopmentEnvironment
|
||||
from agentci.integrations.git import Git
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
from agentci.prompts.library import PromptLibrary
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Runtime:
|
||||
settings: Settings
|
||||
repository: Repository
|
||||
gitea: Gitea
|
||||
git: Git
|
||||
opencode: OpenCode
|
||||
worker: Worker
|
||||
|
||||
async def close(self) -> None:
|
||||
log.info("runtime shutdown started", extra={"operation": "runtime.close"})
|
||||
try:
|
||||
await self.opencode.close()
|
||||
finally:
|
||||
await self.gitea.close()
|
||||
log.info("runtime shutdown completed", extra={"operation": "runtime.close"})
|
||||
|
||||
|
||||
async def build_runtime(settings: Settings) -> Runtime:
|
||||
log.info("runtime initialization started", extra={"operation": "runtime.build"})
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
||||
repository = Repository(settings.database_path)
|
||||
await repository.initialize()
|
||||
gitea = Gitea(settings.gitea_url, settings.gitea_token)
|
||||
git = Git(
|
||||
gitea_url=settings.gitea_url,
|
||||
username=settings.bot_username,
|
||||
token=settings.gitea_token,
|
||||
askpass_path=settings.askpass_path,
|
||||
commit_name=settings.bot_name,
|
||||
commit_email=settings.bot_email,
|
||||
)
|
||||
prompts = PromptLibrary()
|
||||
opencode = OpenCode(
|
||||
base_url=settings.opencode_url,
|
||||
username=settings.opencode_server_username,
|
||||
password=settings.opencode_server_password,
|
||||
schemas_dir=prompts.schemas_dir,
|
||||
health_directory=settings.workspaces_dir,
|
||||
required_models=(
|
||||
(settings.plan_model, settings.plan_variant),
|
||||
(settings.implement_model, settings.implement_variant),
|
||||
(settings.explore_model, settings.explore_variant),
|
||||
(settings.research_model, settings.research_variant),
|
||||
),
|
||||
timeout_seconds=settings.turn_timeout_seconds,
|
||||
)
|
||||
development = DevelopmentEnvironment(
|
||||
scripts=settings.install_scripts,
|
||||
scripts_dir=settings.install_scripts_dir,
|
||||
tools_dir=settings.dev_tools_dir,
|
||||
timeout_seconds=settings.install_script_timeout_seconds,
|
||||
python_version=settings.python_version,
|
||||
dotnet_channel=settings.dotnet_channel,
|
||||
)
|
||||
services = WorkflowServices(
|
||||
settings=settings,
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
opencode=opencode,
|
||||
prompts=prompts,
|
||||
development=development,
|
||||
)
|
||||
worker = Worker(
|
||||
repository=repository,
|
||||
gitea=gitea,
|
||||
opencode=opencode,
|
||||
services=services,
|
||||
poll_seconds=settings.worker_poll_seconds,
|
||||
max_concurrent_jobs=settings.max_concurrent_jobs,
|
||||
workspaces_dir=settings.workspaces_dir,
|
||||
bot_username=settings.bot_username,
|
||||
)
|
||||
runtime = Runtime(settings, repository, gitea, git, opencode, worker)
|
||||
log.info("runtime initialization completed", extra={"operation": "runtime.build"})
|
||||
return runtime
|
||||
@@ -0,0 +1 @@
|
||||
"""Durable task runner and task handlers."""
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import PermissionDenied, PermissionGranted
|
||||
from agentci.engine.model import Job, JobStatus, Task
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
|
||||
|
||||
async def authorize_job(
|
||||
*, task: Task, job: Job, repository: Repository, gitea: Gitea
|
||||
) -> None:
|
||||
if job.status is not JobStatus.RECEIVED:
|
||||
return
|
||||
permitted = await gitea.has_write_permission(job.repo_owner, job.repo_name, job.requester)
|
||||
event = PermissionGranted(job_id=job.id) if permitted else PermissionDenied(job_id=job.id)
|
||||
outcome = "permission-granted" if permitted else "permission-denied"
|
||||
await repository.apply(f"task:{task.id}:{outcome}", event)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import CommentLinked
|
||||
from agentci.engine.model import Job, Task
|
||||
from agentci.engine.reducer import render_job_comment
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
|
||||
|
||||
async def reconcile_comment(
|
||||
*,
|
||||
task: Task,
|
||||
job: Job,
|
||||
repository: Repository,
|
||||
gitea: Gitea,
|
||||
bot_username: str,
|
||||
) -> None:
|
||||
latest = await repository.get_job(job.id)
|
||||
if latest is None:
|
||||
return
|
||||
body = render_job_comment(latest)
|
||||
comment_id = latest.accepted_comment_id
|
||||
if comment_id is not None and await gitea.update_comment(
|
||||
latest.repo_owner, latest.repo_name, comment_id, body
|
||||
):
|
||||
return
|
||||
marker = f"<!-- agentci:job id={latest.id} -->"
|
||||
matches = sorted(
|
||||
comment.id
|
||||
for comment in await gitea.issue_comments(
|
||||
latest.repo_owner, latest.repo_name, latest.issue_number
|
||||
)
|
||||
if comment.body.startswith(marker)
|
||||
and comment.author.casefold() == bot_username.casefold()
|
||||
)
|
||||
if matches:
|
||||
comment_id = matches[0]
|
||||
else:
|
||||
comment_id = await gitea.create_comment(
|
||||
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
||||
)
|
||||
await repository.apply(
|
||||
f"task:{task.id}:comment:{comment_id}",
|
||||
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
||||
)
|
||||
await gitea.update_comment(
|
||||
latest.repo_owner, latest.repo_name, comment_id, body
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def safe_error(error: Exception) -> str:
|
||||
message = " ".join(str(error).split())
|
||||
return f"{type(error).__name__}: {message}"[:1000]
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.application.worker.errors import safe_error
|
||||
from agentci.engine.events import JobCompleted, JobFailed, JobStarted, ServiceRestarted
|
||||
from agentci.engine.events import JobRejected as RejectedEvent
|
||||
from agentci.engine.model import Job, JobStatus, Task
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows.dispatch import dispatch
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
async def execute_job(
|
||||
*, task: Task, job: Job, repository: Repository, services: WorkflowServices
|
||||
) -> None:
|
||||
if job.status is JobStatus.RUNNING:
|
||||
await repository.apply(
|
||||
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
|
||||
)
|
||||
return
|
||||
if job.status is not JobStatus.QUEUED:
|
||||
return
|
||||
result = await repository.apply(f"task:{task.id}:started", JobStarted(job_id=job.id))
|
||||
running = result.job
|
||||
run = JobRun(repository, job.id, task.id)
|
||||
try:
|
||||
body = await dispatch(running, run, services)
|
||||
except JobRejected as exc:
|
||||
await repository.apply(
|
||||
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
|
||||
)
|
||||
except Exception as exc:
|
||||
latest = await repository.get_job(job.id)
|
||||
stage = latest.stage if latest else running.stage
|
||||
await repository.apply(
|
||||
f"task:{task.id}:failed",
|
||||
JobFailed(job_id=job.id, error=safe_error(exc), stage=stage),
|
||||
)
|
||||
else:
|
||||
await repository.apply(
|
||||
f"task:{task.id}:completed",
|
||||
JobCompleted(
|
||||
job_id=job.id,
|
||||
comment_body=body or "Agent job completed.",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import ServiceRestarted
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
|
||||
async def recover_jobs(repository: Repository) -> None:
|
||||
await repository.recover_tasks()
|
||||
for job in await repository.running_jobs():
|
||||
await repository.apply(
|
||||
f"recovery:{job.id}:service-restarted",
|
||||
ServiceRestarted(job_id=job.id),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.application.worker.authorization import authorize_job
|
||||
from agentci.application.worker.comments import reconcile_comment
|
||||
from agentci.application.worker.errors import safe_error
|
||||
from agentci.application.worker.execution import execute_job
|
||||
from agentci.application.worker.recovery import recover_jobs
|
||||
from agentci.application.worker.sessions import abort_job_sessions
|
||||
from agentci.engine.model import Job, QueueName, Task, TaskKind
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: Repository,
|
||||
gitea: Gitea,
|
||||
opencode: OpenCode,
|
||||
services: WorkflowServices,
|
||||
poll_seconds: float,
|
||||
max_concurrent_jobs: int,
|
||||
workspaces_dir: Path,
|
||||
bot_username: str,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.gitea = gitea
|
||||
self.opencode = opencode
|
||||
self.services = services
|
||||
self.poll_seconds = poll_seconds
|
||||
self.max_concurrent_jobs = max_concurrent_jobs
|
||||
self.workspaces_dir = workspaces_dir
|
||||
self.bot_username = bot_username
|
||||
|
||||
async def run(self, stop: asyncio.Event) -> None:
|
||||
await self._recover()
|
||||
await asyncio.gather(
|
||||
self._loop(QueueName.CONTROL, stop),
|
||||
*(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
|
||||
)
|
||||
|
||||
async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
|
||||
while not stop.is_set():
|
||||
if queue is QueueName.JOBS and not await self.opencode.ready():
|
||||
await self._wait(stop)
|
||||
continue
|
||||
task = await self.repository.claim_task(queue)
|
||||
if task is None:
|
||||
await self._wait(stop)
|
||||
continue
|
||||
try:
|
||||
await self._handle(task)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"listener failed",
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"listener": task.kind.value,
|
||||
"queue": queue.value,
|
||||
},
|
||||
)
|
||||
await self.repository.retry_task(task.id, task.attempts, safe_error(exc))
|
||||
else:
|
||||
await self.repository.complete_task(task.id)
|
||||
|
||||
async def _handle(self, task: Task) -> None:
|
||||
job = await self.repository.get_job(task.job_id)
|
||||
if job is None:
|
||||
return
|
||||
match task.kind:
|
||||
case TaskKind.AUTHORIZE:
|
||||
await self._authorize(task, job)
|
||||
case TaskKind.EXECUTE:
|
||||
await self._execute(task, job)
|
||||
case TaskKind.RECONCILE_COMMENT:
|
||||
await self._reconcile(task, job)
|
||||
case TaskKind.FAIL_WORKFLOW:
|
||||
await self.repository.fail_job_workflow(job.id)
|
||||
case TaskKind.ABORT_SESSIONS:
|
||||
await self._abort_job_sessions(job)
|
||||
case _:
|
||||
raise RuntimeError(f"Unknown task kind {task.kind}")
|
||||
|
||||
async def _authorize(self, task: Task, job: Job) -> None:
|
||||
await authorize_job(
|
||||
task=task,
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
gitea=self.gitea,
|
||||
)
|
||||
|
||||
async def _execute(self, task: Task, job: Job) -> None:
|
||||
await execute_job(
|
||||
task=task,
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
services=self.services,
|
||||
)
|
||||
|
||||
async def _reconcile(self, task: Task, job: Job) -> None:
|
||||
await reconcile_comment(
|
||||
task=task,
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
gitea=self.gitea,
|
||||
bot_username=self.bot_username,
|
||||
)
|
||||
|
||||
async def _recover(self) -> None:
|
||||
await recover_jobs(self.repository)
|
||||
|
||||
async def _abort_job_sessions(self, job: Job) -> None:
|
||||
await abort_job_sessions(
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
opencode=self.opencode,
|
||||
workspaces_dir=self.workspaces_dir,
|
||||
)
|
||||
|
||||
async def _wait(self, stop: asyncio.Event) -> None:
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.engine.model import Job
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
|
||||
|
||||
async def abort_job_sessions(
|
||||
*,
|
||||
job: Job,
|
||||
repository: Repository,
|
||||
opencode: OpenCode,
|
||||
workspaces_dir: Path,
|
||||
) -> None:
|
||||
sessions: set[tuple[str, Path]] = set()
|
||||
workflow = await repository.get_workflow(job.workflow_id) if job.workflow_id else None
|
||||
if workflow:
|
||||
sessions.update(
|
||||
(session, workflow.workspace_path)
|
||||
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
|
||||
if session
|
||||
)
|
||||
elif job.runtime_session_id:
|
||||
sessions.add(
|
||||
(job.runtime_session_id, workspaces_dir / f"fix-{job.id}" / "repo")
|
||||
)
|
||||
for session, workspace in sessions:
|
||||
await opencode.abort(session, workspace)
|
||||
@@ -0,0 +1 @@
|
||||
"""Service configuration."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Domain types and policies."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Durable job and workflow engine."""
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.engine.events import JobEvent
|
||||
from agentci.engine.model import (
|
||||
Job,
|
||||
JobKind,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
Task,
|
||||
TaskKind,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
WorkflowStatus,
|
||||
)
|
||||
from agentci.engine.reducer import Transition
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def connect(path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(path, timeout=30)
|
||||
try:
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
except Exception:
|
||||
connection.close()
|
||||
raise
|
||||
return connection
|
||||
|
||||
|
||||
def initialize(connection: sqlite3.Connection, migrations_dir: Path) -> None:
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations "
|
||||
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
|
||||
)
|
||||
applied = {row[0] for row in connection.execute("SELECT version FROM schema_migrations")}
|
||||
for path in sorted(migrations_dir.glob("*.sql")):
|
||||
version = int(path.name.split("_", 1)[0])
|
||||
if version in applied:
|
||||
continue
|
||||
connection.executescript(path.read_text())
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)",
|
||||
(version, now()),
|
||||
)
|
||||
|
||||
|
||||
def insert_event(
|
||||
connection: sqlite3.Connection,
|
||||
event_id: str,
|
||||
event: JobEvent,
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
|
||||
(event_id, event.job_id, event.type, event.model_dump_json(), timestamp),
|
||||
)
|
||||
|
||||
|
||||
def insert_tasks(
|
||||
connection: sqlite3.Connection,
|
||||
event_id: str,
|
||||
transition: Transition,
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
for ordinal, task in enumerate(transition.tasks):
|
||||
connection.execute(
|
||||
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
|
||||
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
transition.job.id,
|
||||
event_id,
|
||||
ordinal,
|
||||
task.kind.value,
|
||||
task.queue.value,
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def insert_job(connection: sqlite3.Connection, job: Job, timestamp: str) -> None:
|
||||
connection.execute(
|
||||
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
|
||||
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
|
||||
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
|
||||
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(*_job_values(job), timestamp),
|
||||
)
|
||||
|
||||
|
||||
def replace_job(
|
||||
connection: sqlite3.Connection,
|
||||
job: Job,
|
||||
previous: Job,
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
started = (
|
||||
timestamp
|
||||
if previous.status is JobStatus.QUEUED and job.status is JobStatus.RUNNING
|
||||
else None
|
||||
)
|
||||
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
|
||||
finished = timestamp if previous.status not in terminal and job.status in terminal else None
|
||||
connection.execute(
|
||||
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
|
||||
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
|
||||
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
|
||||
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
|
||||
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
|
||||
(*_job_values(job)[1:], started, finished, job.id),
|
||||
)
|
||||
|
||||
|
||||
def optional_job(connection: sqlite3.Connection, job_id: str) -> Job | None:
|
||||
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||
return job_from_row(row) if row else None
|
||||
|
||||
|
||||
def required_job(connection: sqlite3.Connection, job_id: str) -> Job:
|
||||
job = optional_job(connection, job_id)
|
||||
if job is None:
|
||||
raise KeyError(f"Unknown job {job_id}")
|
||||
return job
|
||||
|
||||
|
||||
def job_from_row(row: sqlite3.Row) -> Job:
|
||||
return Job(
|
||||
id=row["id"],
|
||||
kind=JobKind(row["kind"]) if row["kind"] else None,
|
||||
target_key=row["target_key"],
|
||||
repo_owner=row["repo_owner"],
|
||||
repo_name=row["repo_name"],
|
||||
issue_number=row["issue_number"],
|
||||
pr_number=row["pr_number"],
|
||||
requester=row["requester"],
|
||||
message=row["message"],
|
||||
comment_id=row["comment_id"],
|
||||
delivery_id=row["delivery_id"],
|
||||
receive_sequence=row["receive_sequence"],
|
||||
command_body=row["command_body"],
|
||||
workflow_id=row["workflow_id"],
|
||||
status=JobStatus(row["status"]),
|
||||
stage=row["stage"],
|
||||
error=row["error"],
|
||||
runtime_session_id=row["runtime_session_id"],
|
||||
accepted_comment_id=row["accepted_comment_id"],
|
||||
comment_body=row["comment_body"],
|
||||
)
|
||||
|
||||
|
||||
def insert_workflow(
|
||||
connection: sqlite3.Connection,
|
||||
workflow: Workflow,
|
||||
timestamp: str,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
|
||||
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
|
||||
review_json, status, runtime, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
workflow.id,
|
||||
workflow.kind.value,
|
||||
workflow.repo_owner,
|
||||
workflow.repo_name,
|
||||
workflow.issue_number,
|
||||
workflow.pr_number,
|
||||
workflow.base_sha,
|
||||
workflow.branch,
|
||||
str(workflow.workspace_path),
|
||||
workflow.primary_session_id,
|
||||
workflow.reviewer_session_id,
|
||||
workflow.artifact,
|
||||
workflow.review_json,
|
||||
workflow.status.value,
|
||||
workflow.runtime,
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
|
||||
if row is None:
|
||||
return None
|
||||
return Workflow(
|
||||
id=row["id"],
|
||||
kind=WorkflowKind(row["kind"]),
|
||||
repo_owner=row["repo_owner"],
|
||||
repo_name=row["repo_name"],
|
||||
issue_number=row["issue_number"],
|
||||
pr_number=row["pr_number"],
|
||||
base_sha=row["base_sha"],
|
||||
runtime=row["runtime"],
|
||||
branch=row["branch"],
|
||||
workspace_path=Path(row["workspace_path"]),
|
||||
primary_session_id=row["primary_session_id"],
|
||||
reviewer_session_id=row["reviewer_session_id"],
|
||||
artifact=row["artifact"],
|
||||
review_json=row["review_json"],
|
||||
status=WorkflowStatus(row["status"]),
|
||||
)
|
||||
|
||||
|
||||
def claim_task(connection: sqlite3.Connection, queue: str) -> Task | None:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
fifo = ""
|
||||
if queue == QueueName.JOBS.value:
|
||||
fifo = """AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
|
||||
AND earlier.target_key = j.target_key
|
||||
AND earlier.status IN ('received', 'queued', 'running'))"""
|
||||
row = connection.execute(
|
||||
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
|
||||
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
|
||||
ORDER BY {"j.receive_sequence" if queue == QueueName.JOBS.value else "t.id"} LIMIT 1""",
|
||||
(queue, now()),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.commit()
|
||||
return None
|
||||
changed = connection.execute(
|
||||
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
|
||||
"WHERE id=? AND status='pending'",
|
||||
(now(), row["id"]),
|
||||
)
|
||||
if changed.rowcount != 1:
|
||||
connection.rollback()
|
||||
return None
|
||||
connection.commit()
|
||||
return Task(
|
||||
id=row["id"],
|
||||
job_id=row["job_id"],
|
||||
source_event_id=row["source_event_id"],
|
||||
kind=TaskKind(row["listener"]),
|
||||
queue=QueueName(row["queue"]),
|
||||
attempts=row["attempts"] + 1,
|
||||
)
|
||||
|
||||
|
||||
def _job_values(job: Job) -> tuple[object, ...]:
|
||||
return (
|
||||
job.id,
|
||||
job.kind.value if job.kind else None,
|
||||
job.target_key,
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
job.issue_number,
|
||||
job.pr_number,
|
||||
job.requester,
|
||||
job.message,
|
||||
job.comment_id,
|
||||
job.delivery_id,
|
||||
job.receive_sequence,
|
||||
job.command_body,
|
||||
job.workflow_id,
|
||||
job.status.value,
|
||||
job.stage,
|
||||
job.error,
|
||||
job.runtime_session_id,
|
||||
job.accepted_comment_id,
|
||||
job.comment_body,
|
||||
)
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
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]*))?$")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from agentci.engine.commands import CommandError, parse_command, resolve_job_kind
|
||||
from agentci.engine.events import (
|
||||
CommandReceived,
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobEvent,
|
||||
JobFailed,
|
||||
JobProgress,
|
||||
JobRejected,
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
RuntimeSessionLinked,
|
||||
ServiceRestarted,
|
||||
WorkflowCreated,
|
||||
WorkflowLinked,
|
||||
)
|
||||
from agentci.engine.model import Job, JobStatus, QueueName, TaskKind, TaskRequest
|
||||
|
||||
|
||||
class InvalidTransition(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
job: Job
|
||||
tasks: tuple[TaskRequest, ...] = ()
|
||||
|
||||
|
||||
RECONCILE = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)
|
||||
|
||||
|
||||
def reduce_job(current: Job | None, event: JobEvent) -> Transition:
|
||||
if current is None:
|
||||
if not isinstance(event, CommandReceived):
|
||||
raise InvalidTransition("Only CommandReceived can create a job")
|
||||
job = Job(
|
||||
id=event.job_id,
|
||||
target_key=event.target_key,
|
||||
repo_owner=event.repo_owner,
|
||||
repo_name=event.repo_name,
|
||||
issue_number=event.issue_number,
|
||||
pr_number=event.pr_number,
|
||||
requester=event.requester,
|
||||
comment_id=event.comment_id,
|
||||
delivery_id=event.delivery_id,
|
||||
receive_sequence=event.receive_sequence,
|
||||
command_body=event.command_body,
|
||||
)
|
||||
task = TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL)
|
||||
return Transition(job, (task,))
|
||||
if event.job_id != current.id:
|
||||
raise InvalidTransition("Event job ID does not match state")
|
||||
if isinstance(event, CommentLinked):
|
||||
return Transition(replace(current, accepted_comment_id=event.comment_id))
|
||||
if isinstance(event, ServiceRestarted):
|
||||
if current.status is not JobStatus.RUNNING:
|
||||
return Transition(current)
|
||||
failed = replace(
|
||||
current,
|
||||
status=JobStatus.FAILED,
|
||||
stage="interrupted",
|
||||
error="Service restarted during an active OpenCode turn",
|
||||
)
|
||||
tasks = [TaskRequest(TaskKind.ABORT_SESSIONS, QueueName.CONTROL), RECONCILE]
|
||||
if current.workflow_id:
|
||||
tasks.insert(1, TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
|
||||
return Transition(failed, tuple(tasks))
|
||||
if current.status is JobStatus.RECEIVED:
|
||||
return _received(current, event)
|
||||
if current.status is JobStatus.QUEUED and isinstance(event, JobStarted):
|
||||
return Transition(
|
||||
replace(current, status=JobStatus.RUNNING, stage="starting"),
|
||||
(RECONCILE,),
|
||||
)
|
||||
if current.status is JobStatus.RUNNING:
|
||||
return _running(current, event)
|
||||
raise InvalidTransition(f"{event.type} is invalid while job is {current.status}")
|
||||
|
||||
|
||||
def _received(current: Job, event: JobEvent) -> Transition:
|
||||
if isinstance(event, PermissionDenied):
|
||||
reason = "Agent command rejected: repository write permission is required."
|
||||
return Transition(
|
||||
replace(current, status=JobStatus.REJECTED, stage="rejected", error=reason),
|
||||
(RECONCILE,),
|
||||
)
|
||||
if not isinstance(event, PermissionGranted):
|
||||
raise InvalidTransition(f"{event.type} is invalid while job is received")
|
||||
try:
|
||||
command = parse_command(current.command_body)
|
||||
if command is None:
|
||||
raise CommandError("Invalid agent command.")
|
||||
kind = resolve_job_kind(command, is_pull_request=current.is_pull_request)
|
||||
except CommandError as exc:
|
||||
return Transition(
|
||||
replace(current, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
|
||||
(RECONCILE,),
|
||||
)
|
||||
queued = replace(
|
||||
current,
|
||||
kind=kind,
|
||||
message=command.message,
|
||||
status=JobStatus.QUEUED,
|
||||
stage="queued",
|
||||
)
|
||||
tasks = (
|
||||
TaskRequest(TaskKind.EXECUTE, QueueName.JOBS),
|
||||
RECONCILE,
|
||||
)
|
||||
return Transition(queued, tasks)
|
||||
|
||||
|
||||
def _running(current: Job, event: JobEvent) -> Transition:
|
||||
if isinstance(event, JobProgress):
|
||||
return Transition(replace(current, stage=event.stage))
|
||||
if isinstance(event, WorkflowCreated):
|
||||
return Transition(replace(current, workflow_id=event.workflow.id, stage=event.stage))
|
||||
if isinstance(event, WorkflowLinked):
|
||||
return Transition(replace(current, workflow_id=event.workflow_id, stage=event.stage))
|
||||
if isinstance(event, RuntimeSessionLinked):
|
||||
return Transition(replace(current, runtime_session_id=event.session_id))
|
||||
if isinstance(event, JobCompleted):
|
||||
return Transition(
|
||||
replace(
|
||||
current,
|
||||
status=JobStatus.SUCCEEDED,
|
||||
stage="completed",
|
||||
comment_body=event.comment_body,
|
||||
),
|
||||
(RECONCILE,),
|
||||
)
|
||||
if isinstance(event, (JobRejected, JobFailed)):
|
||||
rejected = isinstance(event, JobRejected)
|
||||
error = event.reason if rejected else event.error
|
||||
stage = "rejected" if rejected else event.stage
|
||||
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
|
||||
tasks = [RECONCILE]
|
||||
if current.workflow_id:
|
||||
tasks.append(TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL))
|
||||
return Transition(
|
||||
replace(current, status=status, stage=stage, error=error),
|
||||
tuple(tasks),
|
||||
)
|
||||
raise InvalidTransition(f"{event.type} is invalid while job is running")
|
||||
|
||||
|
||||
def render_job_comment(job: Job) -> str:
|
||||
marker = f"<!-- agentci:job id={job.id} -->"
|
||||
if job.status is JobStatus.SUCCEEDED and job.comment_body:
|
||||
body = job.comment_body
|
||||
elif job.status is JobStatus.REJECTED:
|
||||
body = f"Agent job `{job.id}` was rejected: {job.error}"
|
||||
elif job.status is JobStatus.FAILED:
|
||||
body = f"Agent job `{job.id}` failed during `{job.stage}`: {job.error}"
|
||||
else:
|
||||
kind = job.kind.value if job.kind else "command"
|
||||
body = f"Agent job `{job.id}` {job.status.value} (`{kind}`; stage: `{job.stage}`)."
|
||||
return f"{marker}\n{body}"
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from agentci.engine import _sqlite
|
||||
from agentci.engine.events import CommandReceived, JobEvent, WorkflowCreated
|
||||
from agentci.engine.model import (
|
||||
IncomingCommand,
|
||||
Job,
|
||||
QueueName,
|
||||
Task,
|
||||
Workflow,
|
||||
WorkflowKind,
|
||||
WorkflowStatus,
|
||||
)
|
||||
from agentci.engine.reducer import reduce_job
|
||||
|
||||
T = TypeVar("T")
|
||||
log = logging.getLogger(__name__)
|
||||
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyResult:
|
||||
job: Job
|
||||
duplicate: bool
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, database_path: Path, migrations_dir: Path | None = None) -> None:
|
||||
self.database_path = database_path
|
||||
self.migrations_dir = migrations_dir or Path(__file__).parent.parent / "migrations"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
log.info("database initialization started", extra={"operation": "database.initialize"})
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
await self._run(lambda connection: _sqlite.initialize(connection, self.migrations_dir))
|
||||
except Exception:
|
||||
log.exception(
|
||||
"database initialization failed",
|
||||
extra={"operation": "database.initialize"},
|
||||
)
|
||||
raise
|
||||
log.info("database initialization completed", extra={"operation": "database.initialize"})
|
||||
|
||||
async def accept(self, command: IncomingCommand) -> ApplyResult:
|
||||
job_id = str(uuid5(JOB_NAMESPACE, command.delivery_id))
|
||||
event_id = f"delivery:{command.delivery_id}"
|
||||
|
||||
def operation(connection: sqlite3.Connection) -> ApplyResult:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
duplicate = connection.execute(
|
||||
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
job = _sqlite.required_job(connection, duplicate["job_id"])
|
||||
connection.commit()
|
||||
return ApplyResult(job, True)
|
||||
sequence = connection.execute(
|
||||
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
|
||||
).fetchone()[0]
|
||||
event = CommandReceived(
|
||||
job_id=job_id,
|
||||
delivery_id=command.delivery_id,
|
||||
receive_sequence=sequence,
|
||||
command_body=command.body,
|
||||
target_key=command.target_key,
|
||||
repo_owner=command.repo_owner,
|
||||
repo_name=command.repo_name,
|
||||
issue_number=command.issue_number,
|
||||
pr_number=command.pr_number,
|
||||
requester=command.requester,
|
||||
comment_id=command.comment_id,
|
||||
)
|
||||
transition = reduce_job(None, event)
|
||||
timestamp = _sqlite.now()
|
||||
_sqlite.insert_event(connection, event_id, event, timestamp)
|
||||
_sqlite.insert_job(connection, transition.job, timestamp)
|
||||
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
|
||||
connection.commit()
|
||||
return ApplyResult(transition.job, False)
|
||||
|
||||
return await self._run(operation)
|
||||
|
||||
async def apply(self, event_id: str, event: JobEvent) -> ApplyResult:
|
||||
def operation(connection: sqlite3.Connection) -> ApplyResult:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
duplicate = connection.execute(
|
||||
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
existing_job_id = duplicate["job_id"]
|
||||
if existing_job_id != event.job_id:
|
||||
raise RuntimeError(
|
||||
f"Event ID {event_id!r} belongs to job {existing_job_id!r}, "
|
||||
f"not {event.job_id!r}"
|
||||
)
|
||||
job = _sqlite.required_job(connection, existing_job_id)
|
||||
connection.commit()
|
||||
return ApplyResult(job, True)
|
||||
current = _sqlite.required_job(connection, event.job_id)
|
||||
transition = reduce_job(current, event)
|
||||
timestamp = _sqlite.now()
|
||||
_sqlite.insert_event(connection, event_id, event, timestamp)
|
||||
if isinstance(event, WorkflowCreated):
|
||||
_sqlite.insert_workflow(connection, event.workflow, timestamp)
|
||||
_sqlite.replace_job(connection, transition.job, current, timestamp)
|
||||
_sqlite.insert_tasks(connection, event_id, transition, timestamp)
|
||||
connection.commit()
|
||||
return ApplyResult(transition.job, False)
|
||||
|
||||
return await self._run(operation)
|
||||
|
||||
async def get_job(self, job_id: str) -> Job | None:
|
||||
return await self._run(lambda connection: _sqlite.optional_job(connection, job_id))
|
||||
|
||||
async def claim_task(self, queue: QueueName | str) -> Task | None:
|
||||
queue_name = queue.value if isinstance(queue, QueueName) else queue
|
||||
return await self._run(lambda connection: _sqlite.claim_task(connection, queue_name))
|
||||
|
||||
async def complete_task(self, task_id: int) -> None:
|
||||
await self._run(
|
||||
lambda connection: connection.execute(
|
||||
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
|
||||
(_sqlite.now(), task_id),
|
||||
)
|
||||
)
|
||||
|
||||
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
|
||||
delay = min(2 ** min(attempts, 8), 300)
|
||||
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
|
||||
await self._run(
|
||||
lambda connection: connection.execute(
|
||||
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
|
||||
(available, error[:1000], task_id),
|
||||
)
|
||||
)
|
||||
|
||||
async def recover_tasks(self) -> None:
|
||||
def operation(connection: sqlite3.Connection) -> None:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""UPDATE listener_tasks SET status='pending', started_at=NULL
|
||||
WHERE status='running' AND queue='control'"""
|
||||
)
|
||||
connection.execute(
|
||||
"""UPDATE listener_tasks SET status='pending', started_at=NULL
|
||||
WHERE status='running' AND listener='execute' AND job_id IN
|
||||
(SELECT id FROM jobs WHERE status='queued')"""
|
||||
)
|
||||
connection.execute(
|
||||
"""UPDATE listener_tasks SET status='failed', finished_at=?,
|
||||
error='Service restarted after execution began'
|
||||
WHERE status='running' AND listener='execute' AND job_id IN
|
||||
(SELECT id FROM jobs WHERE status<>'queued')""",
|
||||
(_sqlite.now(),),
|
||||
)
|
||||
|
||||
await self._run(operation)
|
||||
|
||||
async def running_jobs(self) -> list[Job]:
|
||||
return await self._run(
|
||||
lambda connection: [
|
||||
_sqlite.job_from_row(row)
|
||||
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
|
||||
]
|
||||
)
|
||||
|
||||
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
|
||||
return await self._run(
|
||||
lambda connection: {
|
||||
value
|
||||
for row in connection.execute(
|
||||
"SELECT accepted_comment_id, started_comment_id FROM jobs "
|
||||
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
|
||||
(owner, repo, issue),
|
||||
)
|
||||
for value in row
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
|
||||
async def get_workflow(self, workflow_id: str) -> Workflow | None:
|
||||
return await self._run(
|
||||
lambda connection: _sqlite.workflow_from_row(
|
||||
connection.execute("SELECT * FROM workflows WHERE id=?", (workflow_id,)).fetchone()
|
||||
)
|
||||
)
|
||||
|
||||
async def save_workflow(self, workflow: Workflow) -> None:
|
||||
def operation(connection: sqlite3.Connection) -> None:
|
||||
updated = connection.execute(
|
||||
"""UPDATE workflows SET pr_number=?, branch=?, primary_session_id=?,
|
||||
reviewer_session_id=?, artifact=?, review_json=?, status=?, updated_at=?
|
||||
WHERE id=?""",
|
||||
(
|
||||
workflow.pr_number,
|
||||
workflow.branch,
|
||||
workflow.primary_session_id,
|
||||
workflow.reviewer_session_id,
|
||||
workflow.artifact,
|
||||
workflow.review_json,
|
||||
workflow.status.value,
|
||||
_sqlite.now(),
|
||||
workflow.id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise KeyError(f"Unknown workflow {workflow.id}")
|
||||
|
||||
await self._run(operation)
|
||||
|
||||
async def latest_workflow(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue: int,
|
||||
kind: WorkflowKind,
|
||||
) -> Workflow | None:
|
||||
return await self._run(
|
||||
lambda connection: _sqlite.workflow_from_row(
|
||||
connection.execute(
|
||||
"""SELECT * FROM workflows
|
||||
WHERE repo_owner=? AND repo_name=? AND issue_number=?
|
||||
AND kind=? AND status=?
|
||||
ORDER BY created_at DESC LIMIT 1""",
|
||||
(owner, repo, issue, kind.value, WorkflowStatus.COMPLETED.value),
|
||||
).fetchone()
|
||||
)
|
||||
)
|
||||
|
||||
async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None:
|
||||
return await self._run(
|
||||
lambda connection: _sqlite.workflow_from_row(
|
||||
connection.execute(
|
||||
"""SELECT * FROM workflows
|
||||
WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=?
|
||||
ORDER BY created_at DESC LIMIT 1""",
|
||||
(owner, repo, pr, WorkflowKind.IMPLEMENT.value),
|
||||
).fetchone()
|
||||
)
|
||||
)
|
||||
|
||||
async def implementation_workflows(self, owner: str, repo: str, issue: int) -> list[Workflow]:
|
||||
return await self._run(
|
||||
lambda connection: [
|
||||
workflow
|
||||
for row in connection.execute(
|
||||
"""SELECT * FROM workflows
|
||||
WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=?
|
||||
AND pr_number IS NOT NULL
|
||||
ORDER BY created_at DESC""",
|
||||
(owner, repo, issue, WorkflowKind.IMPLEMENT.value),
|
||||
)
|
||||
if (workflow := _sqlite.workflow_from_row(row)) is not None
|
||||
]
|
||||
)
|
||||
|
||||
async def fail_job_workflow(self, job_id: str) -> None:
|
||||
await self._run(
|
||||
lambda connection: connection.execute(
|
||||
"""UPDATE workflows SET status=?, updated_at=?
|
||||
WHERE id=(SELECT workflow_id FROM jobs WHERE id=?)
|
||||
AND status=?""",
|
||||
(
|
||||
WorkflowStatus.FAILED.value,
|
||||
_sqlite.now(),
|
||||
job_id,
|
||||
WorkflowStatus.ACTIVE.value,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T:
|
||||
connection = _sqlite.connect(self.database_path)
|
||||
try:
|
||||
with connection:
|
||||
return operation(connection)
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import (
|
||||
JobProgress,
|
||||
RuntimeSessionLinked,
|
||||
WorkflowCreated,
|
||||
WorkflowLinked,
|
||||
)
|
||||
from agentci.engine.model import Workflow
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
type ReportEvent = JobProgress | RuntimeSessionLinked | WorkflowCreated | WorkflowLinked
|
||||
|
||||
|
||||
class JobRun:
|
||||
def __init__(self, repository: Repository, job_id: str, task_id: int) -> None:
|
||||
self.repository = repository
|
||||
self.job_id = job_id
|
||||
self.task_id = task_id
|
||||
self._sequence = 0
|
||||
|
||||
async def stage(self, stage: str) -> None:
|
||||
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
|
||||
|
||||
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
|
||||
|
||||
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||
await self._emit(
|
||||
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
|
||||
)
|
||||
|
||||
async def link_session(self, session_id: str) -> None:
|
||||
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
|
||||
|
||||
async def _emit(self, event: ReportEvent) -> None:
|
||||
self._sequence += 1
|
||||
event_id = f"task:{self.task_id}:report:{self._sequence}"
|
||||
await self.repository.apply(event_id, event)
|
||||
@@ -0,0 +1 @@
|
||||
"""External service and process integrations."""
|
||||
@@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
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,10 +41,11 @@ class DevelopmentEnvironment:
|
||||
async def prepare(self, workspace: Path) -> None:
|
||||
if not self.scripts:
|
||||
return
|
||||
self.tools_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.tools_dir / "bin").mkdir(exist_ok=True)
|
||||
for name in self.scripts:
|
||||
await self._run(name, self._resolve(name), workspace)
|
||||
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:
|
||||
await self._run(name, self._resolve(name), workspace)
|
||||
|
||||
def _resolve(self, name: str) -> Path:
|
||||
path = self.scripts_dir / name
|
||||
@@ -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."""
|
||||
@@ -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;
|
||||
@@ -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 +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."""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
|
||||
class PromptLibrary:
|
||||
def __init__(self, directory: Path | None = None) -> None:
|
||||
self.directory = directory or Path(__file__).parent
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
template = Template((self.directory / f"{name}.md").read_text())
|
||||
return template.substitute(values)
|
||||
|
||||
@property
|
||||
def schemas_dir(self) -> Path:
|
||||
return self.directory / "schemas"
|
||||
@@ -1,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 +1 @@
|
||||
"""Codex workflow orchestration."""
|
||||
|
||||
"""Functional workflow orchestration."""
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.domain.models import AgentResult, Job
|
||||
from agentci.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"
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -1,81 +1,104 @@
|
||||
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 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} \u2014 {issue.title}\n"
|
||||
f"State: {issue.state}\n\n"
|
||||
f"## Issue body\n{issue.body or '(empty)'}\n\n"
|
||||
f"## Discussion\n{discussion or '(none)'}"
|
||||
)
|
||||
|
||||
async def issue_context(self, owner: str, repo: str, number: int) -> str:
|
||||
issue = await self.gitea.issue(owner, repo, number)
|
||||
comments = await self.gitea.issue_comments(owner, repo, number)
|
||||
operational = await self.storage.operational_comment_ids(owner, repo, number)
|
||||
discussion = "\n\n".join(
|
||||
_format_comment(comment) for comment in comments if comment.id not in operational
|
||||
|
||||
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', '')}"
|
||||
for item in commits
|
||||
)
|
||||
context = (
|
||||
f"Repository: {owner}/{repo}\n"
|
||||
f"Pull request: #{number} \u2014 {pull.title}\n"
|
||||
f"State: {pull.state}; merged: {pull.merged}\n"
|
||||
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
|
||||
f"{pull.head_branch} @ {pull.head_sha}\n\n"
|
||||
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
|
||||
f"## Commits\n{commit_text or '(none)'}\n\n"
|
||||
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
|
||||
f"## Formal and inline reviews\n{review_text or '(none)'}"
|
||||
)
|
||||
return pull, context
|
||||
|
||||
|
||||
async def _format_reviews(
|
||||
gitea: Gitea,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
reviews: list[dict[str, Any]],
|
||||
) -> str:
|
||||
details = [
|
||||
(
|
||||
int(review["id"]),
|
||||
review.get("user", {}).get("login", "unknown"),
|
||||
review.get("state", "unknown"),
|
||||
review.get("body") or "(empty)",
|
||||
)
|
||||
return (
|
||||
f"Repository: {owner}/{repo}\n"
|
||||
f"Issue: #{number} — {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)'}"
|
||||
for review in reviews
|
||||
]
|
||||
comment_groups = await asyncio.gather(
|
||||
*(
|
||||
gitea.review_comments(owner, repo, number, review_id)
|
||||
for review_id, _, _, _ in details
|
||||
)
|
||||
|
||||
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)
|
||||
timeline_text = "\n\n".join(_format_comment(item) for item in timeline)
|
||||
commit_text = "\n".join(
|
||||
f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}"
|
||||
for item in commits
|
||||
)
|
||||
context = (
|
||||
f"Repository: {owner}/{repo}\n"
|
||||
f"Pull request: #{number} — {pull.title}\n"
|
||||
f"State: {pull.state}; merged: {pull.merged}\n"
|
||||
f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:"
|
||||
f"{pull.head_branch} @ {pull.head_sha}\n\n"
|
||||
f"## Pull request body\n{pull.body or '(empty)'}\n\n"
|
||||
f"## Commits\n{commit_text or '(none)'}\n\n"
|
||||
f"## Timeline discussion\n{timeline_text or '(none)'}\n\n"
|
||||
f"## Formal and inline reviews\n{review_text or '(none)'}"
|
||||
)
|
||||
return pull, context
|
||||
|
||||
async def _format_reviews(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
reviews: list[dict[str, Any]],
|
||||
) -> str:
|
||||
sections: list[str] = []
|
||||
for review in reviews:
|
||||
review_id = int(review["id"])
|
||||
author = review.get("user", {}).get("login", "unknown")
|
||||
state = review.get("state", "unknown")
|
||||
body = review.get("body") or "(empty)"
|
||||
lines = [f"### Review {review_id} by {author} ({state})\n{body}"]
|
||||
for comment in await self.gitea.review_comments(owner, repo, number, review_id):
|
||||
path = comment.get("path") or "unknown file"
|
||||
line = comment.get("new_position") or comment.get("old_position") or "?"
|
||||
text = comment.get("body") or ""
|
||||
lines.append(f"- `{path}:{line}`: {text}")
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
)
|
||||
sections: list[str] = []
|
||||
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 comments:
|
||||
path = comment.get("path") or "unknown file"
|
||||
line = comment.get("new_position") or comment.get("old_position") or "?"
|
||||
text = comment.get("body") or ""
|
||||
lines.append(f"- `{path}:{line}`: {text}")
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _format_comment(comment: CommentInfo) -> str:
|
||||
return f"### {comment.author} at {comment.created_at}\n{comment.body}"
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agentci.engine.model import Job, JobKind
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows.implementation import implement
|
||||
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
|
||||
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def dispatch(job: Job, run: JobRun, services: WorkflowServices) -> str:
|
||||
if job.kind is None:
|
||||
raise RuntimeError("Cannot dispatch an unparsed command")
|
||||
extra = {
|
||||
"operation": "workflow.dispatch",
|
||||
"job_id": job.id,
|
||||
"target": job.target_key,
|
||||
"stage": job.kind.value,
|
||||
}
|
||||
log.info("workflow dispatch started", extra=extra)
|
||||
try:
|
||||
match job.kind:
|
||||
case JobKind.PLAN:
|
||||
body = await create_plan(job, run, services)
|
||||
case JobKind.DISCUSS:
|
||||
body = await discuss_plan(job, run, services)
|
||||
case JobKind.ITERATE_PLAN:
|
||||
body = await iterate_plan(job, run, services)
|
||||
case JobKind.IMPLEMENT:
|
||||
body = await implement(job, run, services)
|
||||
case JobKind.ITERATE_IMPLEMENT:
|
||||
body = await iterate_implementation(job, run, services)
|
||||
case JobKind.FIX:
|
||||
body = await fix_pull_request(job, run, services)
|
||||
case _:
|
||||
raise KeyError(job.kind)
|
||||
except Exception:
|
||||
log.exception("workflow dispatch failed", extra=extra)
|
||||
raise
|
||||
log.info("workflow dispatch completed", extra=extra)
|
||||
return body
|
||||
@@ -1,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)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from agentci.engine.model import Job, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows.context import build_issue_context
|
||||
from agentci.workflows.model import AgentResult
|
||||
from agentci.workflows.render import (
|
||||
JobRejected,
|
||||
commit_title,
|
||||
final_comment,
|
||||
pull_request_body,
|
||||
result_comment,
|
||||
)
|
||||
from agentci.workflows.review import review_implementation_loop
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
async def implement(job: Job, run: JobRun, services: WorkflowServices) -> str:
|
||||
await _reject_duplicate(job, services)
|
||||
default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name)
|
||||
issue = await services.gitea.issue(job.repo_owner, job.repo_name, job.issue_number)
|
||||
workflow_id = str(uuid4())
|
||||
branch = (
|
||||
f"{services.settings.branch_prefix}/issue-{job.issue_number}-"
|
||||
f"{workflow_id[:8]}"
|
||||
)
|
||||
workspace = services.settings.workspaces_dir / workflow_id / "repo"
|
||||
|
||||
await run.stage("cloning")
|
||||
base_sha = await services.git.clone(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
default_branch,
|
||||
workspace,
|
||||
)
|
||||
await services.git.create_branch(workspace, branch)
|
||||
workflow = Workflow(
|
||||
id=workflow_id,
|
||||
kind=WorkflowKind.IMPLEMENT,
|
||||
repo_owner=job.repo_owner,
|
||||
repo_name=job.repo_name,
|
||||
issue_number=job.issue_number,
|
||||
workspace_path=workspace,
|
||||
base_sha=base_sha,
|
||||
branch=branch,
|
||||
)
|
||||
await run.create_workflow(workflow, "installing development environment")
|
||||
await services.development.prepare(workspace)
|
||||
|
||||
await run.stage("implementing")
|
||||
context = await build_issue_context(
|
||||
services.gitea,
|
||||
services.repository,
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
job.issue_number,
|
||||
)
|
||||
plan = await services.repository.latest_workflow(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
job.issue_number,
|
||||
WorkflowKind.PLAN,
|
||||
)
|
||||
plan_artifact = plan.artifact if plan and plan.artifact else "(no canonical plan)"
|
||||
prompt = services.prompts.render(
|
||||
"implement_initial",
|
||||
context=context,
|
||||
artifact=plan_artifact,
|
||||
request=job.message or "(no additional request)",
|
||||
development_environment=services.development.description,
|
||||
)
|
||||
session_id = await services.opencode.create_session(workspace, "implementation")
|
||||
workflow = replace(workflow, primary_session_id=session_id)
|
||||
await services.repository.save_workflow(workflow)
|
||||
await run.link_session(session_id)
|
||||
|
||||
result = await services.opencode.resume(
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
prompt=prompt,
|
||||
model=services.settings.implement_model,
|
||||
variant=services.settings.implement_variant,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
workflow = replace(workflow, artifact=result.model_dump_json())
|
||||
await services.repository.save_workflow(workflow)
|
||||
|
||||
workflow, result, report = await review_implementation_loop(
|
||||
workflow,
|
||||
context,
|
||||
plan_artifact,
|
||||
result,
|
||||
run,
|
||||
services,
|
||||
)
|
||||
sha = await commit_and_push(
|
||||
run,
|
||||
services,
|
||||
workspace,
|
||||
branch,
|
||||
result,
|
||||
set_upstream=True,
|
||||
commit_prefix="agent",
|
||||
)
|
||||
|
||||
await run.stage("creating pull request")
|
||||
pull = await services.gitea.create_pull_request(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
title=f"Agent: {issue.title}",
|
||||
body=pull_request_body(job.issue_number, result),
|
||||
head=branch,
|
||||
base=default_branch,
|
||||
)
|
||||
workflow = replace(
|
||||
workflow,
|
||||
pr_number=pull.number,
|
||||
artifact=result.model_dump_json(),
|
||||
review_json=report.model_dump_json(),
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await services.repository.save_workflow(workflow)
|
||||
|
||||
pull_url = (
|
||||
f"{services.settings.gitea_url}/{job.repo_owner}/"
|
||||
f"{job.repo_name}/pulls/{pull.number}"
|
||||
)
|
||||
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
|
||||
return final_comment("implementation", workflow.id, body, report)
|
||||
|
||||
|
||||
async def commit_and_push(
|
||||
run: JobRun,
|
||||
services: WorkflowServices,
|
||||
workspace: Path,
|
||||
branch: str,
|
||||
result: AgentResult,
|
||||
*,
|
||||
set_upstream: bool,
|
||||
commit_prefix: str,
|
||||
) -> str:
|
||||
await run.stage("validating changes")
|
||||
if not await services.git.has_changes(workspace):
|
||||
raise JobRejected("OpenCode completed without producing any file changes.")
|
||||
await services.git.diff_check(workspace)
|
||||
title = commit_title(result.summary_markdown)
|
||||
await run.stage("committing changes")
|
||||
sha = await services.git.commit(workspace, f"{commit_prefix}: {title}")
|
||||
await run.stage("pushing changes")
|
||||
await services.git.push(workspace, branch, set_upstream=set_upstream)
|
||||
return sha
|
||||
|
||||
|
||||
async def _reject_duplicate(job: Job, services: WorkflowServices) -> None:
|
||||
workflows = await services.repository.implementation_workflows(
|
||||
job.repo_owner, job.repo_name, job.issue_number
|
||||
)
|
||||
for workflow in workflows:
|
||||
if workflow.pr_number is None:
|
||||
continue
|
||||
pull = await services.gitea.pull_request(
|
||||
job.repo_owner, job.repo_name, workflow.pr_number
|
||||
)
|
||||
if pull.is_open:
|
||||
raise JobRejected(
|
||||
f"Agent PR #{pull.number} is already open. Use `/agent iterate` "
|
||||
"on that pull request."
|
||||
)
|
||||
if pull.merged:
|
||||
raise JobRejected(
|
||||
f"Agent PR #{pull.number} has already been merged for this issue."
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ReviewSeverity(StrEnum):
|
||||
BLOCKING = "blocking"
|
||||
MAJOR = "major"
|
||||
MINOR = "minor"
|
||||
|
||||
|
||||
class PlanArtifact(BaseModel):
|
||||
plan_markdown: str = Field(min_length=1)
|
||||
|
||||
|
||||
class DiscussionReply(BaseModel):
|
||||
markdown: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AgentResult(BaseModel):
|
||||
summary_markdown: str = Field(min_length=1)
|
||||
tests: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewFinding(BaseModel):
|
||||
severity: ReviewSeverity
|
||||
title: str
|
||||
detail: str
|
||||
location: str | None = None
|
||||
recommendation: str
|
||||
|
||||
|
||||
class ReviewReport(BaseModel):
|
||||
summary: str
|
||||
findings: list[ReviewFinding] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_serious_findings(self) -> bool:
|
||||
return any(
|
||||
finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR}
|
||||
for finding in self.findings
|
||||
)
|
||||
+169
-227
@@ -1,247 +1,189 @@
|
||||
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 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 = services.settings.workspaces_dir / workflow_id / "repo"
|
||||
|
||||
async def plan(self, job: Job) -> None:
|
||||
repository = await self.deps.gitea.repository(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(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
repository.default_branch,
|
||||
workspace,
|
||||
)
|
||||
workflow = Workflow(
|
||||
id=workflow_id,
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner=job.repo_owner,
|
||||
repo_name=job.repo_name,
|
||||
issue_number=job.issue_number,
|
||||
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
|
||||
)
|
||||
prompt = self.deps.prompts.render(
|
||||
"plan_initial",
|
||||
context=context,
|
||||
request=job.message or "(no additional request)",
|
||||
)
|
||||
session_id, artifact = await self.deps.codex.start(
|
||||
workspace=workspace,
|
||||
prompt=prompt,
|
||||
model=self.deps.settings.plan_model,
|
||||
reasoning=self.deps.settings.plan_reasoning,
|
||||
permission="agentci-read",
|
||||
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)
|
||||
await run.stage("cloning")
|
||||
base_sha = await services.git.clone(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
default_branch,
|
||||
workspace,
|
||||
)
|
||||
workflow = Workflow(
|
||||
id=workflow_id,
|
||||
kind=WorkflowKind.PLAN,
|
||||
repo_owner=job.repo_owner,
|
||||
repo_name=job.repo_name,
|
||||
issue_number=job.issue_number,
|
||||
workspace_path=workspace,
|
||||
base_sha=base_sha,
|
||||
)
|
||||
await run.create_workflow(workflow, "planning")
|
||||
|
||||
async def discuss(self, job: Job) -> None:
|
||||
workflow = await self._latest_plan(job)
|
||||
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
|
||||
)
|
||||
reply = await self.deps.codex.resume(
|
||||
session_id=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="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),
|
||||
)
|
||||
context = await build_issue_context(
|
||||
services.gitea,
|
||||
services.repository,
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
job.issue_number,
|
||||
)
|
||||
prompt = services.prompts.render(
|
||||
"plan_initial",
|
||||
context=context,
|
||||
request=job.message or "(no additional request)",
|
||||
)
|
||||
session_id = await services.opencode.create_session(workspace, "plan")
|
||||
workflow = replace(workflow, primary_session_id=session_id)
|
||||
await services.repository.save_workflow(workflow)
|
||||
await run.link_session(session_id)
|
||||
|
||||
async def iterate(self, job: Job) -> None:
|
||||
await self._reject_if_active_or_merged_pr(job)
|
||||
workflow = await self._latest_plan(job)
|
||||
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"
|
||||
)
|
||||
context = await self.deps.context.issue_context(
|
||||
job.repo_owner, job.repo_name, job.issue_number
|
||||
)
|
||||
prompt = self.deps.prompts.render(
|
||||
"plan_iterate",
|
||||
context=context,
|
||||
artifact=workflow.artifact,
|
||||
review=report_for_prompt(workflow.review_json),
|
||||
message=job.message or "(refine using the latest discussion and prior review)",
|
||||
)
|
||||
artifact = await self.deps.codex.resume(
|
||||
session_id=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,
|
||||
)
|
||||
report = await self._review(workflow, context, artifact)
|
||||
await self._finish(job, workflow, artifact, report)
|
||||
artifact = await services.opencode.resume(
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
prompt=prompt,
|
||||
model=services.settings.plan_model,
|
||||
variant=services.settings.plan_variant,
|
||||
schema_name="plan.json",
|
||||
result_type=PlanArtifact,
|
||||
)
|
||||
workflow = replace(workflow, artifact=artifact.plan_markdown)
|
||||
await services.repository.save_workflow(workflow)
|
||||
|
||||
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}",
|
||||
workflow, artifact, report = await review_plan_loop(
|
||||
workflow, context, artifact, run, services
|
||||
)
|
||||
workflow = replace(
|
||||
workflow,
|
||||
artifact=artifact.plan_markdown,
|
||||
review_json=report.model_dump_json(),
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await services.repository.save_workflow(workflow)
|
||||
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
|
||||
|
||||
|
||||
async def discuss_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
|
||||
workflow = await _latest_plan(job, services)
|
||||
if workflow.runtime != "opencode":
|
||||
raise JobRejected(
|
||||
"The latest plan predates OpenCode and cannot be resumed; "
|
||||
"start a new `/agent plan`."
|
||||
)
|
||||
if not workflow.primary_session_id or not workflow.artifact:
|
||||
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
|
||||
|
||||
await run.link_workflow(workflow.id, "discussing")
|
||||
prompt = services.prompts.render(
|
||||
"discuss", artifact=workflow.artifact, message=job.message or ""
|
||||
)
|
||||
reply = await services.opencode.resume(
|
||||
session_id=workflow.primary_session_id,
|
||||
prompt=prompt,
|
||||
model=services.settings.plan_model,
|
||||
variant=services.settings.plan_variant,
|
||||
workspace=workflow.workspace_path,
|
||||
schema_name="discussion.json",
|
||||
result_type=DiscussionReply,
|
||||
)
|
||||
return agent_comment("discussion", workflow.id, reply.markdown)
|
||||
|
||||
|
||||
async def iterate_plan(job: Job, run: JobRun, services: WorkflowServices) -> str:
|
||||
await _reject_if_active_or_merged_pr(job, services)
|
||||
workflow = await _latest_plan(job, services)
|
||||
if workflow.runtime != "opencode":
|
||||
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
|
||||
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
||||
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
|
||||
if not workflow.artifact:
|
||||
raise JobRejected("The latest plan has no saved artifact.")
|
||||
|
||||
await run.link_workflow(workflow.id, "iterating plan")
|
||||
context = await build_issue_context(
|
||||
services.gitea,
|
||||
services.repository,
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
job.issue_number,
|
||||
)
|
||||
prompt = services.prompts.render(
|
||||
"plan_iterate",
|
||||
context=context,
|
||||
artifact=workflow.artifact,
|
||||
review=report_for_prompt(workflow.review_json),
|
||||
message=job.message or "(refine using the latest discussion and prior review)",
|
||||
)
|
||||
artifact = await services.opencode.resume(
|
||||
session_id=workflow.primary_session_id,
|
||||
prompt=prompt,
|
||||
model=services.settings.plan_model,
|
||||
variant=services.settings.plan_variant,
|
||||
workspace=workflow.workspace_path,
|
||||
schema_name="plan.json",
|
||||
result_type=PlanArtifact,
|
||||
)
|
||||
workflow, report = await review_plan_once(workflow, context, artifact, services)
|
||||
workflow = replace(
|
||||
workflow,
|
||||
artifact=artifact.plan_markdown,
|
||||
review_json=report.model_dump_json(),
|
||||
status=WorkflowStatus.COMPLETED,
|
||||
)
|
||||
await services.repository.save_workflow(workflow)
|
||||
return final_comment("plan", workflow.id, artifact.plan_markdown, report)
|
||||
|
||||
|
||||
async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow:
|
||||
workflow = await services.repository.latest_workflow(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
job.issue_number,
|
||||
WorkflowKind.PLAN,
|
||||
)
|
||||
if workflow is None:
|
||||
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
|
||||
return workflow
|
||||
|
||||
|
||||
async def _reject_if_active_or_merged_pr(
|
||||
job: Job, services: WorkflowServices
|
||||
) -> None:
|
||||
workflows = await services.repository.implementation_workflows(
|
||||
job.repo_owner, job.repo_name, job.issue_number
|
||||
)
|
||||
for workflow in workflows:
|
||||
if workflow.pr_number is None:
|
||||
continue
|
||||
pull = await services.gitea.pull_request(
|
||||
job.repo_owner, job.repo_name, workflow.pr_number
|
||||
)
|
||||
if pull.is_open or pull.merged:
|
||||
raise JobRejected(
|
||||
f"Issue plan iteration is disabled because agent PR #{pull.number} "
|
||||
"is open or merged. Iterate an open implementation on its PR."
|
||||
)
|
||||
report = await self._review(workflow, context, artifact)
|
||||
workflow.artifact = artifact.plan_markdown
|
||||
workflow.review_json = report_json(report)
|
||||
await self.deps.storage.update_workflow(workflow)
|
||||
if not report.has_serious_findings:
|
||||
break
|
||||
if round_index == self.deps.settings.plan_review_rounds - 1:
|
||||
break
|
||||
prompt = self.deps.prompts.render(
|
||||
"plan_revision",
|
||||
artifact=artifact.plan_markdown,
|
||||
review=report_for_prompt(workflow.review_json),
|
||||
)
|
||||
artifact = await self.deps.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
|
||||
|
||||
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(
|
||||
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
|
||||
)
|
||||
if workflow is None:
|
||||
raise JobRejected("No completed plan exists. Start with `/agent plan`.")
|
||||
return workflow
|
||||
|
||||
async def _reject_if_active_or_merged_pr(self, job: Job) -> None:
|
||||
workflows = await self.deps.storage.implementation_workflows(
|
||||
job.repo_owner, job.repo_name, job.issue_number
|
||||
)
|
||||
for workflow in workflows:
|
||||
if workflow.pr_number is None:
|
||||
continue
|
||||
pull = await self.deps.gitea.pull_request(
|
||||
job.repo_owner, job.repo_name, workflow.pr_number
|
||||
)
|
||||
if pull.is_open or pull.merged:
|
||||
raise JobRejected(
|
||||
f"Issue plan iteration is disabled because agent PR #{pull.number} "
|
||||
"is open or merged. Iterate an open implementation on its PR."
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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_implementation(
|
||||
job: Job, run: JobRun, services: WorkflowServices
|
||||
) -> str:
|
||||
pull_number = _pull_number(job)
|
||||
workflow = await services.repository.workflow_for_pr(
|
||||
job.repo_owner, job.repo_name, pull_number
|
||||
)
|
||||
if workflow is None or workflow.status is not WorkflowStatus.COMPLETED:
|
||||
raise JobRejected(
|
||||
"This is not an open agent-created implementation PR. Use `/agent fix`."
|
||||
)
|
||||
if workflow.runtime != "opencode":
|
||||
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
|
||||
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
||||
raise JobRejected("The implementation sessions cannot be resumed.")
|
||||
|
||||
async def iterate(self, job: Job) -> None:
|
||||
pull_number = _pull_number(job)
|
||||
workflow = await self.deps.storage.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 not workflow.primary_session_id or not workflow.reviewer_session_id:
|
||||
raise JobRejected("The implementation sessions cannot be resumed.")
|
||||
pull, context = await self.deps.context.pull_request_context(
|
||||
job.repo_owner, job.repo_name, pull_number
|
||||
)
|
||||
if not pull.is_open:
|
||||
raise JobRejected("Implementation iteration requires an open pull request.")
|
||||
if workflow.branch != pull.head_branch:
|
||||
raise JobRejected("The pull request head branch no longer matches its workflow.")
|
||||
await 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(
|
||||
"implementation_iterate",
|
||||
context=context,
|
||||
review=report_for_prompt(workflow.review_json),
|
||||
message=job.message or "(perform one additional reviewed refinement)",
|
||||
development_environment=self.deps.development.description,
|
||||
)
|
||||
result = await self.deps.codex.resume(
|
||||
session_id=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,
|
||||
)
|
||||
issue_context = await self.deps.context.issue_context(
|
||||
job.repo_owner, job.repo_name, workflow.issue_number
|
||||
)
|
||||
plan = await self.deps.storage.latest_workflow(
|
||||
job.repo_owner, job.repo_name, workflow.issue_number, WorkflowKind.PLAN
|
||||
)
|
||||
report = await self.review.once(
|
||||
workflow,
|
||||
issue_context=issue_context,
|
||||
plan=plan.artifact if plan and plan.artifact else "(no canonical plan)",
|
||||
pull_context=context,
|
||||
)
|
||||
sha = await self.changes.commit_and_push(
|
||||
job,
|
||||
workflow.workspace_path,
|
||||
pull.head_branch,
|
||||
result,
|
||||
set_upstream=False,
|
||||
commit_prefix="agent iterate",
|
||||
)
|
||||
workflow.artifact = result.model_dump_json()
|
||||
workflow.review_json = report_json(report)
|
||||
await self.deps.storage.update_workflow(workflow)
|
||||
await self.deps.gitea.create_comment(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
pull_number,
|
||||
agent_comment("iteration", workflow.id, result_comment(result, sha=sha)),
|
||||
)
|
||||
remaining = review_markdown(report)
|
||||
if remaining:
|
||||
await self.deps.gitea.create_comment(
|
||||
job.repo_owner, job.repo_name, pull_number, remaining
|
||||
)
|
||||
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.")
|
||||
|
||||
async def fix(self, job: Job) -> None:
|
||||
pull_number = _pull_number(job)
|
||||
pull, context = await self.deps.context.pull_request_context(
|
||||
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(
|
||||
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(
|
||||
"fix",
|
||||
context=context,
|
||||
message=job.message or "(address the pull request feedback)",
|
||||
development_environment=self.deps.development.description,
|
||||
)
|
||||
await self.deps.storage.update_job(job.id, stage="fixing")
|
||||
_, 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,
|
||||
)
|
||||
sha = await self.changes.commit_and_push(
|
||||
job,
|
||||
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)),
|
||||
)
|
||||
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=services.development.description,
|
||||
)
|
||||
result = await services.opencode.resume(
|
||||
session_id=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,
|
||||
)
|
||||
|
||||
issue_context = await build_issue_context(
|
||||
services.gitea,
|
||||
services.repository,
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
workflow.issue_number,
|
||||
)
|
||||
plan = await services.repository.latest_workflow(
|
||||
job.repo_owner,
|
||||
job.repo_name,
|
||||
workflow.issue_number,
|
||||
WorkflowKind.PLAN,
|
||||
)
|
||||
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 commit_and_push(
|
||||
run,
|
||||
services,
|
||||
workflow.workspace_path,
|
||||
pull.head_branch,
|
||||
result,
|
||||
set_upstream=False,
|
||||
commit_prefix="agent iterate",
|
||||
)
|
||||
workflow = replace(
|
||||
workflow,
|
||||
artifact=result.model_dump_json(),
|
||||
review_json=report.model_dump_json(),
|
||||
)
|
||||
await services.repository.save_workflow(workflow)
|
||||
return final_comment(
|
||||
"iteration", workflow.id, result_comment(result, sha=sha), report
|
||||
)
|
||||
|
||||
|
||||
async def fix_pull_request(
|
||||
job: Job, run: JobRun, services: WorkflowServices
|
||||
) -> str:
|
||||
pull_number = _pull_number(job)
|
||||
pull, context = await build_pull_request_context(
|
||||
services.gitea, job.repo_owner, job.repo_name, pull_number
|
||||
)
|
||||
if not pull.is_open:
|
||||
raise JobRejected("Fixes require an open pull request.")
|
||||
|
||||
workspace = services.settings.workspaces_dir / f"fix-{job.id}" / "repo"
|
||||
await run.stage("cloning pull request")
|
||||
await services.git.clone(
|
||||
pull.head_owner,
|
||||
pull.head_repo,
|
||||
pull.head_branch,
|
||||
workspace,
|
||||
)
|
||||
await run.stage("installing development environment")
|
||||
await services.development.prepare(workspace)
|
||||
prompt = services.prompts.render(
|
||||
"fix",
|
||||
context=context,
|
||||
message=job.message or "(address the pull request feedback)",
|
||||
development_environment=services.development.description,
|
||||
)
|
||||
await run.stage("fixing")
|
||||
session_id = await services.opencode.create_session(workspace, "fix")
|
||||
await run.link_session(session_id)
|
||||
result = await services.opencode.resume(
|
||||
session_id=session_id,
|
||||
workspace=workspace,
|
||||
prompt=prompt,
|
||||
model=services.settings.implement_model,
|
||||
variant=services.settings.implement_variant,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
sha = await commit_and_push(
|
||||
run,
|
||||
services,
|
||||
workspace,
|
||||
pull.head_branch,
|
||||
result,
|
||||
set_upstream=False,
|
||||
commit_prefix="agent fix",
|
||||
)
|
||||
return agent_comment("fix", job.id, result_comment(result, sha=sha))
|
||||
|
||||
|
||||
def _pull_number(job: Job) -> int:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from agentci.workflows.model import AgentResult, ReviewReport
|
||||
|
||||
|
||||
class JobRejected(RuntimeError):
|
||||
"""A safe, expected workflow rejection to publish to the requester."""
|
||||
|
||||
|
||||
def required_session(value: str | None) -> str:
|
||||
if value is None:
|
||||
raise RuntimeError("Expected a persisted OpenCode session ID")
|
||||
return value
|
||||
|
||||
|
||||
def review_markdown(report: ReviewReport) -> str:
|
||||
if not report.findings:
|
||||
return ""
|
||||
lines = ["## Remaining review findings", "", report.summary]
|
||||
for finding in report.findings:
|
||||
location = f" \u2014 `{finding.location}`" if finding.location else ""
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"### {finding.severity.value.upper()}: {finding.title}{location}",
|
||||
finding.detail,
|
||||
"",
|
||||
f"Recommendation: {finding.recommendation}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def report_for_prompt(stored_review: str | None) -> str:
|
||||
if not stored_review:
|
||||
return "(none)"
|
||||
try:
|
||||
return json.dumps(json.loads(stored_review), indent=2)
|
||||
except json.JSONDecodeError:
|
||||
return stored_review
|
||||
|
||||
|
||||
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
|
||||
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
|
||||
|
||||
|
||||
def final_comment(
|
||||
kind: str,
|
||||
workflow_id: str,
|
||||
body: str,
|
||||
report: ReviewReport | None = None,
|
||||
) -> str:
|
||||
rendered = agent_comment(kind, workflow_id, body)
|
||||
remaining = review_markdown(report) if report else ""
|
||||
return f"{rendered}\n\n{remaining}" if remaining else rendered
|
||||
|
||||
|
||||
def pull_request_body(issue_number: int, result: AgentResult) -> str:
|
||||
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
|
||||
return (
|
||||
f"Closes #{issue_number}\n\n"
|
||||
f"## Implementation\n\n{result.summary_markdown}\n\n"
|
||||
f"## Validation\n\n{tests}\n\n"
|
||||
"_Created by Agent CI._"
|
||||
)
|
||||
|
||||
|
||||
def result_comment(result: AgentResult, *, sha: str | None = None) -> str:
|
||||
tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported"
|
||||
commit = f"\n\nCommit: `{sha}`" if sha else ""
|
||||
return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}"
|
||||
|
||||
|
||||
def commit_title(markdown: str) -> str:
|
||||
for line in markdown.splitlines():
|
||||
value = line.strip().lstrip("#").strip()
|
||||
if value:
|
||||
return value[:72]
|
||||
return "apply requested changes"
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from agentci.engine.model import Workflow
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows.model import AgentResult, PlanArtifact, ReviewReport
|
||||
from agentci.workflows.render import report_for_prompt, required_session
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
async def review_plan_loop(
|
||||
workflow: Workflow,
|
||||
context: str,
|
||||
artifact: PlanArtifact,
|
||||
run: JobRun,
|
||||
services: WorkflowServices,
|
||||
) -> tuple[Workflow, PlanArtifact, ReviewReport]:
|
||||
report = ReviewReport(summary="", findings=[])
|
||||
for round_index in range(services.settings.plan_review_rounds):
|
||||
await run.stage(
|
||||
f"reviewing plan {round_index + 1}/{services.settings.plan_review_rounds}"
|
||||
)
|
||||
workflow, report = await review_plan_once(
|
||||
workflow, context, artifact, services
|
||||
)
|
||||
workflow = replace(
|
||||
workflow,
|
||||
artifact=artifact.plan_markdown,
|
||||
review_json=report.model_dump_json(),
|
||||
)
|
||||
await services.repository.save_workflow(workflow)
|
||||
if not report.has_serious_findings:
|
||||
break
|
||||
if round_index == services.settings.plan_review_rounds - 1:
|
||||
break
|
||||
prompt = services.prompts.render(
|
||||
"plan_revision",
|
||||
artifact=artifact.plan_markdown,
|
||||
review=report_for_prompt(workflow.review_json),
|
||||
)
|
||||
artifact = await services.opencode.resume(
|
||||
session_id=required_session(workflow.primary_session_id),
|
||||
prompt=prompt,
|
||||
model=services.settings.plan_model,
|
||||
variant=services.settings.plan_variant,
|
||||
workspace=workflow.workspace_path,
|
||||
schema_name="plan.json",
|
||||
result_type=PlanArtifact,
|
||||
)
|
||||
workflow = replace(workflow, artifact=artifact.plan_markdown)
|
||||
await services.repository.save_workflow(workflow)
|
||||
return workflow, artifact, report
|
||||
|
||||
|
||||
async def review_plan_once(
|
||||
workflow: Workflow,
|
||||
context: str,
|
||||
artifact: PlanArtifact,
|
||||
services: WorkflowServices,
|
||||
) -> tuple[Workflow, ReviewReport]:
|
||||
prompt = services.prompts.render(
|
||||
"plan_review", context=context, artifact=artifact.plan_markdown
|
||||
)
|
||||
if workflow.reviewer_session_id:
|
||||
report = await services.opencode.resume(
|
||||
session_id=workflow.reviewer_session_id,
|
||||
prompt=prompt,
|
||||
model=services.settings.plan_model,
|
||||
variant=services.settings.plan_variant,
|
||||
workspace=workflow.workspace_path,
|
||||
schema_name="review.json",
|
||||
result_type=ReviewReport,
|
||||
)
|
||||
return workflow, report
|
||||
|
||||
session_id = await services.opencode.create_session(
|
||||
workflow.workspace_path, "plan-review"
|
||||
)
|
||||
workflow = replace(workflow, reviewer_session_id=session_id)
|
||||
await services.repository.save_workflow(workflow)
|
||||
report = await services.opencode.resume(
|
||||
session_id=session_id,
|
||||
workspace=workflow.workspace_path,
|
||||
prompt=prompt,
|
||||
model=services.settings.plan_model,
|
||||
variant=services.settings.plan_variant,
|
||||
schema_name="review.json",
|
||||
result_type=ReviewReport,
|
||||
)
|
||||
return workflow, report
|
||||
|
||||
|
||||
async def review_implementation_loop(
|
||||
workflow: Workflow,
|
||||
issue_context: str,
|
||||
plan: str,
|
||||
result: AgentResult,
|
||||
run: JobRun,
|
||||
services: WorkflowServices,
|
||||
) -> tuple[Workflow, AgentResult, ReviewReport]:
|
||||
report = ReviewReport(summary="", findings=[])
|
||||
for round_index in range(services.settings.implement_review_rounds):
|
||||
await run.stage(
|
||||
f"reviewing implementation {round_index + 1}/"
|
||||
f"{services.settings.implement_review_rounds}"
|
||||
)
|
||||
workflow, report = await review_implementation_once(
|
||||
workflow,
|
||||
issue_context=issue_context,
|
||||
plan=plan,
|
||||
pull_context=(
|
||||
"The proposed pull request is the current uncommitted working-tree diff. "
|
||||
"Review only that diff."
|
||||
),
|
||||
services=services,
|
||||
)
|
||||
workflow = replace(
|
||||
workflow,
|
||||
artifact=result.model_dump_json(),
|
||||
review_json=report.model_dump_json(),
|
||||
)
|
||||
await services.repository.save_workflow(workflow)
|
||||
if not report.has_serious_findings:
|
||||
break
|
||||
if round_index == services.settings.implement_review_rounds - 1:
|
||||
break
|
||||
prompt = services.prompts.render(
|
||||
"implementation_revision",
|
||||
review=report_for_prompt(workflow.review_json),
|
||||
development_environment=services.development.description,
|
||||
)
|
||||
result = await services.opencode.resume(
|
||||
session_id=required_session(workflow.primary_session_id),
|
||||
prompt=prompt,
|
||||
model=services.settings.implement_model,
|
||||
variant=services.settings.implement_variant,
|
||||
workspace=workflow.workspace_path,
|
||||
schema_name="agent_result.json",
|
||||
result_type=AgentResult,
|
||||
)
|
||||
workflow = replace(workflow, artifact=result.model_dump_json())
|
||||
await services.repository.save_workflow(workflow)
|
||||
return workflow, result, report
|
||||
|
||||
|
||||
async def review_implementation_once(
|
||||
workflow: Workflow,
|
||||
*,
|
||||
issue_context: str,
|
||||
plan: str,
|
||||
pull_context: str,
|
||||
services: WorkflowServices,
|
||||
) -> tuple[Workflow, ReviewReport]:
|
||||
prompt = services.prompts.render(
|
||||
"implementation_review",
|
||||
issue_context=issue_context,
|
||||
artifact=plan,
|
||||
pull_context=pull_context,
|
||||
)
|
||||
if workflow.reviewer_session_id:
|
||||
report = await services.opencode.resume(
|
||||
session_id=workflow.reviewer_session_id,
|
||||
prompt=prompt,
|
||||
model=services.settings.implement_model,
|
||||
variant=services.settings.implement_variant,
|
||||
workspace=workflow.workspace_path,
|
||||
schema_name="review.json",
|
||||
result_type=ReviewReport,
|
||||
)
|
||||
return workflow, report
|
||||
|
||||
session_id = await services.opencode.create_session(
|
||||
workflow.workspace_path, "implementation-review"
|
||||
)
|
||||
workflow = replace(workflow, reviewer_session_id=session_id)
|
||||
await services.repository.save_workflow(workflow)
|
||||
report = await services.opencode.resume(
|
||||
session_id=session_id,
|
||||
workspace=workflow.workspace_path,
|
||||
prompt=prompt,
|
||||
model=services.settings.implement_model,
|
||||
variant=services.settings.implement_variant,
|
||||
schema_name="review.json",
|
||||
result_type=ReviewReport,
|
||||
)
|
||||
return workflow, report
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agentci.config.settings import Settings
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.development import DevelopmentEnvironment
|
||||
from agentci.integrations.git import Git
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
from agentci.prompts.library import PromptLibrary
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowServices:
|
||||
settings: Settings
|
||||
repository: Repository
|
||||
gitea: Gitea
|
||||
git: Git
|
||||
opencode: OpenCode
|
||||
prompts: PromptLibrary
|
||||
development: DevelopmentEnvironment
|
||||
@@ -0,0 +1,26 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.engine import _sqlite
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine_repository(tmp_path: Path) -> Repository:
|
||||
repository = Repository(tmp_path / "state.sqlite3")
|
||||
await repository.initialize()
|
||||
return repository
|
||||
|
||||
|
||||
@dataclass
|
||||
class SQLiteClock:
|
||||
now: str = "2026-02-01T00:00:00+00:00"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_clock(monkeypatch: pytest.MonkeyPatch) -> SQLiteClock:
|
||||
clock = SQLiteClock()
|
||||
monkeypatch.setattr(_sqlite, "now", lambda: clock.now)
|
||||
return clock
|
||||
@@ -0,0 +1,130 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import agentci.api.app as app_module
|
||||
import agentci.api.lifespan as lifespan_module
|
||||
|
||||
|
||||
class BlockingWorker:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
self.started = asyncio.Event()
|
||||
|
||||
async def run(self, stop: asyncio.Event) -> None:
|
||||
self.events.append("worker-started")
|
||||
self.started.set()
|
||||
try:
|
||||
await stop.wait()
|
||||
except asyncio.CancelledError:
|
||||
self.events.append(f"worker-cancelled:{stop.is_set()}")
|
||||
raise
|
||||
|
||||
|
||||
class FailingWorker:
|
||||
def __init__(self) -> None:
|
||||
self.started = asyncio.Event()
|
||||
|
||||
async def run(self, _stop: asyncio.Event) -> None:
|
||||
self.started.set()
|
||||
raise RuntimeError("worker failed")
|
||||
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, worker: object, events: list[str]) -> None:
|
||||
self.worker = worker
|
||||
self.events = events
|
||||
self.closed = False
|
||||
|
||||
async def close(self) -> None:
|
||||
self.events.append("runtime-closed")
|
||||
self.closed = True
|
||||
|
||||
|
||||
async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
worker = BlockingWorker(events)
|
||||
runtime = FakeRuntime(worker, events)
|
||||
selected_settings = SimpleNamespace(name="selected")
|
||||
built_with: list[object] = []
|
||||
configured: list[bool] = []
|
||||
|
||||
async def build(settings: object) -> FakeRuntime:
|
||||
built_with.append(settings)
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
||||
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
||||
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
|
||||
|
||||
async with application.router.lifespan_context(application):
|
||||
await worker.started.wait()
|
||||
assert application.state.runtime is runtime
|
||||
assert not runtime.closed
|
||||
|
||||
assert built_with == [selected_settings]
|
||||
assert configured == [True]
|
||||
assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"]
|
||||
|
||||
|
||||
async def test_lifespan_closes_runtime_when_worker_task_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
worker = FailingWorker()
|
||||
runtime = FakeRuntime(worker, events)
|
||||
|
||||
async def build(_settings: object) -> FakeRuntime:
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
||||
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None)
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="worker failed"):
|
||||
async with application.router.lifespan_context(application):
|
||||
await worker.started.wait()
|
||||
|
||||
assert runtime.closed
|
||||
assert events == ["runtime-closed"]
|
||||
|
||||
|
||||
async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
configured: list[bool] = []
|
||||
|
||||
async def fail_build(_settings: object) -> None:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(lifespan_module, "build_runtime", fail_build)
|
||||
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
async with application.router.lifespan_context(application):
|
||||
pytest.fail("startup failure must prevent serving requests")
|
||||
|
||||
assert configured == [True]
|
||||
assert not hasattr(application.state, "runtime")
|
||||
|
||||
|
||||
async def test_global_exception_handler_returns_safe_json() -> None:
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
@application.get("/explode")
|
||||
async def explode() -> None:
|
||||
raise RuntimeError("sensitive provider detail")
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.get("/explode")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user