56 changed files with 2500 additions and 1278 deletions
+10 -7
View File
@@ -3,12 +3,16 @@ 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_OPENCODE_VERSION=^1
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
@@ -19,5 +23,4 @@ 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
+33 -22
View File
@@ -1,12 +1,14 @@
ARG TEA_VERSION=0.14.2
FROM node:24-bookworm-slim AS codex
FROM node:24-bookworm-slim AS agent-tools
ARG CODEX_VERSION=0.144.6
ARG CODEGRAPH_VERSION=1.3.1
ARG AGENTCI_OPENCODE_VERSION=^1
RUN npm install --global \
"@openai/codex@${CODEX_VERSION}" \
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
"opencode-ai@${AGENTCI_OPENCODE_VERSION}" \
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}" \
&& version="$(opencode --version)" \
&& case "$version" in 1.*) ;; *) echo "Expected OpenCode 1.x, got $version" >&2; exit 1;; esac
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
@@ -19,30 +21,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \
UV_NO_DEV=1 \
CODEGRAPH_TELEMETRY=0 \
CODEX_HOME=/var/lib/codex \
XDG_CONFIG_HOME=/run/agentci \
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN apt-get update \
&& apt-get install --yes --no-install-recommends \
adduser bubblewrap ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
adduser ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
libicu72 libssl3 libstdc++6 zlib1g \
&& rm -rf /var/lib/apt/lists/* \
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \
&& chown agentci:agentci /run/agentci \
&& chmod u+s /usr/bin/bwrap
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \
/etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \
&& chmod 0555 /etc/opencode/home /etc/opencode/home/.opencode \
/etc/opencode/xdg /etc/opencode/xdg/opencode \
&& chown agentci:agentci /run/agentci
COPY --from=uv /uv /uvx /usr/local/bin/
COPY --from=tea /bin/tea /usr/local/bin/tea
COPY --from=codex /usr/local/bin/node /usr/local/bin/node
COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex
COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \
&& ln -s \
/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \
/usr/local/bin/codex-linux-sandbox \
&& ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
COPY --from=tea /bin/tea /usr/local/libexec/tea
COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node
COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode
COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
/usr/local/bin/codegraph
WORKDIR /opt/agentci
@@ -50,16 +49,28 @@ COPY pyproject.toml uv.lock README.md ./
COPY src ./src
COPY scripts ./scripts
COPY install-scripts /etc/agentci/install-scripts
COPY codex/config.toml /etc/codex/config.toml
COPY opencode /etc/opencode
RUN chmod 0755 \
/opt/agentci/scripts/entrypoint.sh \
/opt/agentci/scripts/gitea-askpass.sh \
/usr/local/bin/tea \
/usr/local/bin/codex \
/opt/agentci/scripts/tea.sh \
/usr/local/libexec/tea \
/usr/local/bin/opencode \
/usr/local/bin/codegraph \
&& ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \
&& uv sync --frozen --no-dev \
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/opencode \
&& opencode --version \
&& OPENCODE_CONFIG=/etc/opencode/opencode.json \
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
OPENCODE_PURE=1 \
AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna \
AGENTCI_EXPLORE_VARIANT=low \
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \
AGENTCI_RESEARCH_VARIANT=high \
CONTEXT7_API_KEY= \
opencode debug config >/dev/null
USER agentci
EXPOSE 8080
+100 -81
View File
@@ -1,8 +1,8 @@
# 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.
## Commands
@@ -15,68 +15,93 @@ 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 that receive
order. 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. Build the image:
```sh
docker compose up --build -d
docker compose build
```
5. Authenticate Codex interactively in the persistent container:
5. Authenticate the configured OpenCode providers before starting the persistent server:
```sh
docker compose exec agentci codex login --device-auth
docker compose exec agentci codex login status
docker compose run --rm opencode opencode auth login
docker compose run --rm opencode opencode auth list
```
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. Start the services:
`/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 up --no-build -d
```
## Configuration
7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
Model, reasoning effort, review-pass counts, bot identity, branch prefix, and
turn timeout use `AGENTCI_` environment variables. Defaults are shown in
`.env.example`. Gitea credentials and webhook secrets are intentionally
file-based Compose secrets.
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`.
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.
`/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.
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
### Development environments
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`.
`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`:
`AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults
to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker
may reuse the cached installation layer until the configured version or build inputs change.
Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built.
The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
Repository-local OpenCode config and external plugins are disabled so a clone cannot replace the
service policy. OpenCode's default plugins remain enabled for provider authentication. Its scanned
home and global configuration paths are root-owned and read-only, and external skill discovery is
disabled, so an agent cannot persist instructions for later repositories. CodeGraph, Context7, and
`gh_grep` are configured as MCP servers. Set `AGENTCI_CONTEXT7_API_KEY` to raise Context7 rate
limits.
The `research` subagent can only use Exa web search, Context7, and the `gh_grep` public-code search
MCP. All filesystem, shell, editing, task, and other tools are denied for that agent. Exa is enabled
with `OPENCODE_ENABLE_EXA=1`. Agent CI initializes or refreshes CodeGraph before every parent turn
and locally excludes `.codegraph/` from Git.
### Security boundary
OpenCode does not provide an OS sandbox. Its default permission system controls agent tools and
approval requests, while Docker limits what the non-root process can reach. Any shell command that
OpenCode permits still has the Unix-level access of that container user, so environment filtering
is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command.
Docker remains the OS boundary. The services run as non-root without added capabilities,
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only
host bind mount is the read-only installer directory; there are no
writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password
protected, and is reachable by Agent CI over an internal Compose network.
## Development environments
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development environment installers.
The supplied `python` and `dotnet` scripts install only their runtimes; they can be replaced or
removed. Configure them with `AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
```env
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
@@ -84,44 +109,36 @@ 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 in `install-scripts/`, mounted read-only at
`/etc/agentci/install-scripts`. 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 `/var/lib/agentci/dev-tools`, and OpenCode can read or
modify them through shell commands permitted by its active agent policy. See
`install-scripts/README.md` for the script contract.
Installers run in order after each implementation clone or branch sync and fail
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 `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes.
The `opencode_home` volume 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
`opencode_home`. Back up both persistent volumes 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 but not later control work.
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 +149,9 @@ uv sync
uv run ruff check .
uv run pyright
uv run pytest
docker compose config
docker compose build
```
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 tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
Python so orchestration modules remain small and readable.
-99
View File
@@ -1,99 +0,0 @@
cli_auth_credentials_store = "file"
approval_policy = "never"
check_for_update_on_startup = false
web_search = "disabled"
default_permissions = "agentci-read"
developer_instructions = """
A custom research subagent named `research` is available in every workflow. Delegate focused
external research to it when current documentation, web evidence, or public code examples would
materially improve the plan or implementation. Keep repository analysis and edits in the parent.
Use CodeGraph for repository architecture, symbol relationships, and change-impact analysis when
it is useful. It is available to planning, implementation, and review agents, but not research.
"""
[mcp_servers.codegraph]
command = "codegraph"
args = ["serve", "--mcp"]
startup_timeout_sec = 60
tool_timeout_sec = 60
[mcp_servers.codegraph.env]
CODEGRAPH_TELEMETRY = "0"
[agents]
max_threads = 4
max_depth = 1
[shell_environment_policy]
inherit = "core"
exclude = ["*TOKEN*", "*SECRET*", "*KEY*", "AGENTCI_*", "GITEA_*"]
[permissions.agentci-read]
description = "Read a workflow repository without modifying it or using the network."
[permissions.agentci-read.filesystem]
":minimal" = "read"
glob_scan_max_depth = 5
[permissions.agentci-read.filesystem.":workspace_roots"]
"." = "read"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-review]
description = "Review scoped workflow material and query Gitea with tea without editing files."
[permissions.agentci-review.filesystem]
":minimal" = "read"
glob_scan_max_depth = 5
[permissions.agentci-review.filesystem.":workspace_roots"]
"." = "read"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-review.network]
enabled = true
allow_local_binding = false
[permissions.agentci-review.network.domains]
"*" = "allow"
[permissions.agentci-write]
description = "Edit a workflow repository without changing Git metadata."
[permissions.agentci-write.filesystem]
":minimal" = "read"
"/var/lib/agentci/dev-tools" = "read"
glob_scan_max_depth = 5
[permissions.agentci-write.filesystem.":workspace_roots"]
"." = "write"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-write.network]
enabled = true
allow_local_binding = false
[permissions.agentci-write.network.domains]
"*" = "allow"
[permissions.agentci-research]
description = "Read a workflow repository and research public internet sources without editing."
[permissions.agentci-research.filesystem]
":minimal" = "read"
glob_scan_max_depth = 5
[permissions.agentci-research.filesystem.":workspace_roots"]
"." = "read"
".git" = "read"
"**/*.env" = "deny"
[permissions.agentci-research.network]
enabled = true
allow_local_binding = false
[permissions.agentci-research.network.domains]
"*" = "allow"
+72 -24
View File
@@ -1,38 +1,31 @@
services:
agentci:
image: agentci:local
build:
context: .
args:
CODEX_VERSION: ${CODEX_VERSION:-0.144.6}
AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
TEA_VERSION: ${TEA_VERSION:-0.14.2}
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}
@@ -43,26 +36,81 @@ 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
tmpfs:
- /run/agentci:mode=1777
expose:
- "8080"
networks:
- gitea
- agentci_control
opencode:
image: agentci:local
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:
- agentci_data:/var/lib/agentci
- opencode_home:/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
opencode_server_password:
file: ./secrets/opencode_server_password
volumes:
agentci_data:
codex_home:
opencode_home:
networks:
gitea:
external: true
name: ${GITEA_NETWORK:-gitea}
agentci_control:
internal: true
+3 -4
View File
@@ -18,10 +18,9 @@ files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/b
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
not persist into implementation turns.
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable
.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a
normal user home and receive read-only access to installed tools, so runtime caches cannot live
beside the SDK.
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state
and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only
so it cannot be used to persist global agent configuration.
The configured scripts run in list order after the repository is cloned (or an existing agent PR
branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and
+4 -3
View File
@@ -6,6 +6,7 @@ set -eu
install_dir="$DEV_TOOLS_DIR/dotnet"
bin_dir="$DEV_TOOLS_DIR/bin"
runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet"
installer=$(mktemp)
trap 'rm -f "$installer"' EXIT
@@ -25,9 +26,9 @@ PYTHON
printf '%s\n' \
'#!/bin/sh' \
"export DOTNET_ROOT='$install_dir'" \
'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \
'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \
'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \
"export DOTNET_CLI_HOME='$runtime_dir/home'" \
"export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \
"export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \
'export HOME="$DOTNET_CLI_HOME"' \
'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \
'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \
+6
View File
@@ -0,0 +1,6 @@
# Agent CI runtime
A `research` subagent is available in every workflow. Delegate focused external research when
current documentation, web evidence, or public code examples would materially improve the result.
Keep repository analysis and edits in the parent agent. Use CodeGraph for repository architecture,
symbol relationships, and change-impact analysis when useful.
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"share": "disabled",
"subagent_depth": 1,
"instructions": ["/etc/opencode/AGENTS.md"],
"agent": {
"explore": {
"model": "{env:AGENTCI_EXPLORE_MODEL}",
"variant": "{env:AGENTCI_EXPLORE_VARIANT}"
},
"research": {
"description": "Research current documentation, web evidence, and public code examples.",
"mode": "subagent",
"model": "{env:AGENTCI_RESEARCH_MODEL}",
"variant": "{env:AGENTCI_RESEARCH_VARIANT}",
"permission": {
"*": "deny",
"websearch": "allow",
"context7_*": "allow",
"gh_grep_*": "allow"
},
"prompt": "Research external, current, or unfamiliar technical facts for the parent agent. Use Context7 for library documentation, gh_grep for public-code examples, and web search for primary sources or broader verification. Prefer authoritative sources, report links, distinguish facts from inference, and return a concise evidence-focused summary. Do not include secrets or proprietary source in external queries."
}
},
"mcp": {
"codegraph": {
"type": "local",
"command": ["codegraph", "serve", "--mcp"],
"environment": {
"CODEGRAPH_TELEMETRY": "0"
},
"enabled": true,
"timeout": 60000
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
},
"enabled": true
},
"gh_grep": {
"type": "remote",
"url": "https://mcp.grep.app",
"enabled": true
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agentci"
version = "0.1.0"
description = "A Gitea webhook host that orchestrates resumable Codex workflows"
description = "A Gitea webhook host that orchestrates resumable OpenCode workflows"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
+8 -1
View File
@@ -1,12 +1,18 @@
#!/bin/sh
set -eu
if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then
export OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE")
fi
if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then
python -c '
import json
import os
from pathlib import Path
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea"
config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.yml"
token_path = Path(
@@ -22,5 +28,6 @@ login = {
config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
config_path.chmod(0o600)
'
fi
exec "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}"
exec /usr/local/libexec/tea "$@"
+1 -2
View File
@@ -1,4 +1,3 @@
"""Gitea-triggered Codex workflow host."""
"""Gitea-triggered OpenCode workflow host."""
__version__ = "0.1.0"
-250
View File
@@ -1,250 +0,0 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
from pathlib import Path
from time import monotonic
from typing import TypeVar
from pydantic import BaseModel
from agentci.adapters.codegraph import CodeGraphClient
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class CodexError(RuntimeError):
pass
class CodexClient:
def __init__(
self,
*,
codex_home: Path,
schemas_dir: Path,
timeout_seconds: int,
research_model: str,
research_reasoning: str,
context7_api_key: str | None,
tools_bin: Path | None = None,
codegraph: CodeGraphClient | None = None,
) -> None:
self.codex_home = codex_home
self.schemas_dir = schemas_dir
self.timeout_seconds = timeout_seconds
self.context7_api_key = context7_api_key
self.tools_bin = tools_bin
self.codegraph = codegraph or CodeGraphClient()
self._write_research_agent(research_model, research_reasoning)
async def login_ready(self) -> bool:
try:
process = await asyncio.create_subprocess_exec(
"codex",
"login",
"status",
env=self._environment(),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError:
return False
return await process.wait() == 0
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
reasoning: str,
permission: str,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
"--json",
"--strict-config",
"-C",
str(workspace),
*self._turn_args(model, reasoning, permission, schema_name),
"-",
]
session_id, result = await self._invoke(args, prompt, result_type, workspace=workspace)
if not session_id:
raise CodexError("Codex did not emit a thread.started event")
return session_id, result
async def resume(
self,
*,
session_id: str,
prompt: str,
model: str,
reasoning: str,
permission: str,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
args = [
"codex",
"exec",
"resume",
"--json",
"--strict-config",
*self._turn_args(model, reasoning, permission, schema_name),
session_id,
"-",
]
_, result = await self._invoke(args, prompt, result_type, workspace=workspace)
return result
def _turn_args(
self,
model: str,
reasoning: str,
permission: str,
schema_name: str,
) -> list[str]:
return [
"--skip-git-repo-check",
"-m",
model,
"-c",
f'model_reasoning_effort="{reasoning}"',
"-c",
f'default_permissions="{permission}"',
"--output-schema",
str(self.schemas_dir / schema_name),
]
async def _invoke(
self, args: list[str], prompt: str, result_type: type[T], *, workspace: Path
) -> tuple[str | None, T]:
started = monotonic()
operation = "codex.resume" if "resume" in args else "codex.start"
log.info("Codex turn started", extra={"operation": operation})
file_descriptor, output_name = tempfile.mkstemp(
prefix="agentci-codex-", suffix=".json"
)
os.close(file_descriptor)
output_path = Path(output_name)
args[-1:-1] = ["--output-last-message", str(output_path)]
try:
process = await asyncio.create_subprocess_exec(
*args,
cwd=workspace,
env=self._environment(),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(prompt.encode()), timeout=self.timeout_seconds
)
except TimeoutError as exc:
process.terminate()
await process.wait()
log.exception(
"Codex turn timed out",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Codex turn exceeded {self.timeout_seconds} seconds") from exc
if process.returncode:
detail = stderr.decode(errors="replace").strip()
log.error(
"Codex turn failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Codex exited with {process.returncode}: {detail[-2000:]}")
session_id = _session_id(stdout.decode(errors="replace"))
text = output_path.read_text() # noqa: ASYNC240 - tiny host-owned result file
result = result_type.model_validate_json(text)
log.info(
"Codex turn completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
return session_id, result
except (OSError, ValueError) as exc:
log.exception(
"Codex result could not be processed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise CodexError(f"Invalid Codex result: {exc}") from exc
finally:
output_path.unlink(missing_ok=True) # noqa: ASYNC240
def _environment(self) -> dict[str, str]:
allowed = {
"PATH",
"LANG",
"LC_ALL",
"SSL_CERT_FILE",
"CODEX_CA_CERTIFICATE",
"XDG_CONFIG_HOME",
}
environment = {key: value for key, value in os.environ.items() if key in allowed}
if self.tools_bin is not None:
environment["PATH"] = f"{self.tools_bin}:{environment.get('PATH', '')}"
environment["CODEX_HOME"] = str(self.codex_home)
if self.context7_api_key:
environment["CONTEXT7_API_KEY"] = self.context7_api_key
return environment
def _write_research_agent(self, model: str, reasoning: str) -> None:
agents_dir = self.codex_home / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
agent = f'''name = "research"
description = "Research specialist for current docs, web evidence, and public code examples."
model = {json.dumps(model)}
model_reasoning_effort = {json.dumps(reasoning)}
default_permissions = "agentci-research"
web_search = "live"
developer_instructions = """
Research external, current, or unfamiliar technical facts for the parent agent.
Use Context7 for library documentation, gh_grep for real public-code examples, and web search for
primary sources or broader verification. Prefer authoritative sources, report links, distinguish
facts from inference, and return a concise evidence-focused summary. You may inspect the workspace
but must not modify it. Never include secrets or proprietary source in external queries.
Do not use CodeGraph; repository analysis belongs to the parent agent.
"""
[mcp_servers.codegraph]
enabled = false
[mcp_servers.context7]
url = "https://mcp.context7.com/mcp"
[mcp_servers.context7.env_http_headers]
CONTEXT7_API_KEY = "CONTEXT7_API_KEY"
[mcp_servers.gh_grep]
url = "https://mcp.grep.app"
'''
(agents_dir / "research.toml").write_text(agent)
def _session_id(output: str) -> str | None:
for line in output.splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "thread.started":
return str(event["thread_id"])
return None
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
+16
View File
@@ -34,6 +34,13 @@ class GiteaClient:
async def close(self) -> None:
await self.client.aclose()
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 repository(self, owner: str, repo: str) -> RepositoryInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return RepositoryInfo(
@@ -98,6 +105,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,
+174 -158
View File
@@ -1,145 +1,177 @@
from __future__ import annotations
import logging
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from agentci.adapters.database import Database, now
from agentci.domain.models import Job, JobKind, JobStatus
from agentci.adapters.state_persistence import (
insert_event as _insert_event,
)
from agentci.adapters.state_persistence import (
insert_state as _insert_state,
)
from agentci.adapters.state_persistence import (
insert_tasks as _insert_tasks,
)
from agentci.adapters.state_persistence import (
insert_workflow as _insert_workflow,
)
from agentci.adapters.state_persistence import (
optional_state as _optional_state,
)
from agentci.adapters.state_persistence import (
replace_state as _replace_state,
)
from agentci.adapters.state_persistence import (
state as _state,
)
from agentci.adapters.state_persistence import (
state_from_row as _state_from_row,
)
from agentci.domain.events import CommandReceived, JobEvent, WorkflowCreated
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState, next_state
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class EvolveResult:
state: JobState
duplicate: bool
@dataclass(frozen=True)
class ListenerTask:
id: int
job_id: str
source_event_id: str
listener: str
queue: str
attempts: int
class JobStore(Database):
async def 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:
async def receive(
self, event_id: str, job_id: str, incoming: CommandEvent
) -> EvolveResult:
def operation(connection: sqlite3.Connection) -> EvolveResult:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT * FROM jobs WHERE status = ? ORDER BY created_at LIMIT 1",
(JobStatus.QUEUED,),
duplicate = connection.execute(
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
).fetchone()
if row is None:
if duplicate:
state = _state(connection, duplicate["job_id"])
connection.commit()
return None
connection.execute(
"UPDATE jobs SET status = ?, stage = ?, started_at = ? WHERE id = ?",
(JobStatus.RUNNING, "starting", now(), row["id"]),
return EvolveResult(state, True)
sequence = connection.execute(
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
).fetchone()[0]
event = CommandReceived(
job_id=job_id,
delivery_id=incoming.delivery_id,
receive_sequence=sequence,
command_body=incoming.body,
target_key=incoming.target_key,
repo_owner=incoming.repo_owner,
repo_name=incoming.repo_name,
issue_number=incoming.issue_number,
pr_number=incoming.pr_number,
requester=incoming.requester,
comment_id=incoming.comment_id,
)
transition = next_state(None, event)
timestamp = now()
_insert_event(connection, event_id, event, timestamp)
_insert_state(connection, transition.state, timestamp)
_insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return job_from_row(row, status=JobStatus.RUNNING, stage="starting")
return EvolveResult(transition.state, False)
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,
},
)
return await self._run(operation)
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")
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
def operation(connection: sqlite3.Connection) -> EvolveResult:
connection.execute("BEGIN IMMEDIATE")
if connection.execute(
"SELECT 1 FROM job_events WHERE event_id=?", (event_id,)
).fetchone():
state = _state(connection, event.job_id)
connection.commit()
return EvolveResult(state, True)
current = _state(connection, event.job_id)
transition = next_state(current, event)
timestamp = now()
_insert_event(connection, event_id, event, timestamp)
if isinstance(event, WorkflowCreated):
_insert_workflow(connection, event, timestamp)
_replace_state(connection, transition.state, current, timestamp)
_insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return EvolveResult(transition.state, False)
return await self._run(operation)
async def get_job_state(self, job_id: str) -> JobState | None:
return await self._run(lambda connection: _optional_state(connection, job_id))
async def claim_task(self, queue: str) -> ListenerTask | None:
return await self._run(lambda connection: _claim_task(connection, queue))
async def complete_task(self, task_id: int) -> None:
await self._run(
lambda connection: connection.execute(
f"UPDATE jobs SET {column} = ? WHERE id = ?", (comment_id, job_id)
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
(now(), task_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"
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),
)
)
return await self._run(select)
async def running_job_states(self) -> list[JobState]:
return await self._run(
lambda connection: [
_state_from_row(row)
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
]
)
async def recover_tasks(self) -> None:
def recover(connection: sqlite3.Connection) -> None:
with connection:
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND queue='control'"""
)
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status='queued')"""
)
connection.execute(
"""UPDATE listener_tasks SET status='failed', finished_at=?,
error='Service restarted after execution began'
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status<>'queued')""",
(now(),),
)
await self._run(recover)
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
return await self._run(
lambda connection: {
value
for row in connection.execute(
"""
SELECT accepted_comment_id, started_comment_id FROM jobs
WHERE repo_owner=? AND repo_name=? AND issue_number=?
""",
"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
@@ -147,49 +179,33 @@ class JobStore(Database):
}
)
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,
),
def _claim_task(connection: sqlite3.Connection, queue: str) -> ListenerTask | None:
connection.execute("BEGIN IMMEDIATE")
fifo = ""
if queue == "jobs":
fifo = """AND NOT EXISTS (
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
AND earlier.status IN ('received', 'queued', 'running'))"""
row = connection.execute(
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""",
(queue, now()),
).fetchone()
if row is None:
connection.commit()
return None
changed = connection.execute(
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
"WHERE id=? AND status='pending'",
(now(), row["id"]),
)
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"],
if changed.rowcount != 1:
connection.rollback()
return None
connection.commit()
return ListenerTask(
row["id"], row["job_id"], row["source_event_id"], row["listener"],
row["queue"], row["attempts"] + 1,
)
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from time import monotonic
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, ValidationError
from agentci.adapters.codegraph import CodeGraphClient
from agentci.adapters.opencode_support import (
api_contract_ready,
directory_headers,
elapsed_ms,
error_message,
load_schema,
model_parts,
models_ready,
)
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class OpenCodeError(RuntimeError):
pass
class OpenCodeClient:
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: CodeGraphClient | 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.timeout_seconds = timeout_seconds
self.codegraph = codegraph or CodeGraphClient()
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:
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:
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 start(
self,
*,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
session_id = await self.create_session(workspace, schema_name)
result = await self.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
return session_id, result
async def create_session(self, workspace: Path, title: str) -> str:
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):
await asyncio.shield(self.abort(session_id, workspace))
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:
await self.abort(session_id, workspace)
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
+77
View File
@@ -0,0 +1,77 @@
import json
from pathlib import Path
from time import monotonic
from typing import Any
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
try:
value = json.loads((schemas_dir / name).read_text())
except (OSError, ValueError) as exc:
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"Result schema {name} is not a JSON object")
return value
def api_contract_ready(document: object) -> bool:
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
return False
paths = document["paths"]
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
if any(method not in paths.get(path, {}) for path, method in fixed.items()):
return False
session_paths = [path for path in paths if path.startswith("/session/{")]
has_message = any(
path.endswith("/message") and "post" in paths[path] for path in session_paths
)
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
return has_message and has_abort
def models_ready(
payload: object, requirements: set[tuple[str, str, str | None]]
) -> bool:
if not isinstance(payload, dict):
return False
connected = set(payload.get("connected", []))
providers = {
item.get("id"): item
for item in payload.get("all", [])
if isinstance(item, dict) and isinstance(item.get("models"), dict)
}
for provider_id, model_id, variant in requirements:
provider = providers.get(provider_id)
if provider_id not in connected or not isinstance(provider, dict):
return False
model: Any = provider["models"].get(model_id)
if not isinstance(model, dict) or model.get("status") == "deprecated":
return False
if model.get("capabilities", {}).get("toolcall") is not True:
return False
if variant and variant not in model.get("variants", {}):
return False
return True
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import sqlite3
from agentci.domain.events import JobEvent, WorkflowCreated
from agentci.domain.models import JobKind, JobStatus
from agentci.domain.state_machine import JobState, Transition
def insert_event(connection: sqlite3.Connection, event_id: str, event: JobEvent, ts: str) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), ts),
)
def insert_tasks(
connection: sqlite3.Connection, event_id: str, transition: Transition, timestamp: str
) -> None:
for ordinal, notification in enumerate(transition.notifications):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.state.id,
event_id,
ordinal,
notification.listener,
notification.queue,
timestamp,
timestamp,
),
)
def _values(state: JobState) -> tuple[object, ...]:
return (
state.id, state.kind, state.target_key, state.repo_owner, state.repo_name,
state.issue_number, state.pr_number, state.requester, state.message, state.comment_id,
state.delivery_id, state.receive_sequence, state.command_body, state.workflow_id,
state.status, state.stage, state.error, state.runtime_session_id,
state.accepted_comment_id, state.comment_body,
)
def insert_state(connection: sqlite3.Connection, state: JobState, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_values(state), timestamp),
)
def replace_state(
connection: sqlite3.Connection, state: JobState, previous: JobState, timestamp: str
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and state.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and state.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_values(state)[1:], started, finished, state.id),
)
def optional_state(connection: sqlite3.Connection, job_id: str) -> JobState | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return state_from_row(row) if row else None
def state(connection: sqlite3.Connection, job_id: str) -> JobState:
value = optional_state(connection, job_id)
if value is None:
raise KeyError(f"Unknown job {job_id}")
return value
def state_from_row(row: sqlite3.Row) -> JobState:
return JobState(
id=row["id"], kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"], repo_owner=row["repo_owner"], repo_name=row["repo_name"],
issue_number=row["issue_number"], pr_number=row["pr_number"], requester=row["requester"],
message=row["message"], comment_id=row["comment_id"], delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"], command_body=row["command_body"],
workflow_id=row["workflow_id"], status=JobStatus(row["status"]), stage=row["stage"],
error=row["error"], runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"], comment_body=row["comment_body"],
)
def insert_workflow(connection: sqlite3.Connection, event: WorkflowCreated, ts: str) -> None:
workflow = event.workflow
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id, workflow.kind, workflow.repo_owner, workflow.repo_name,
workflow.issue_number, workflow.pr_number, workflow.base_sha, workflow.branch,
str(workflow.workspace_path), workflow.primary_session_id,
workflow.reviewer_session_id, workflow.artifact, workflow.review_json,
workflow.status, workflow.runtime, ts, ts,
),
)
+13 -2
View File
@@ -8,6 +8,15 @@ from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus
class WorkflowStore(Database):
async def get_workflow(self, workflow_id: str) -> Workflow | None:
return await self._run(
lambda connection: workflow_from_row(
connection.execute(
"SELECT * FROM workflows WHERE id=?", (workflow_id,)
).fetchone()
)
)
async def create_workflow(self, workflow: Workflow) -> None:
timestamp = now()
await self._run(
@@ -16,9 +25,9 @@ class WorkflowStore(Database):
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,
reviewer_session_id, artifact, review_json, status, runtime,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
workflow.id,
@@ -35,6 +44,7 @@ class WorkflowStore(Database):
workflow.artifact,
workflow.review_json,
workflow.status,
workflow.runtime,
timestamp,
timestamp,
),
@@ -135,6 +145,7 @@ def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
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"],
+2 -3
View File
@@ -12,8 +12,7 @@ async def live() -> dict[str, str]:
@router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]:
if not await request.app.state.container.codex.login_ready():
if not await request.app.state.container.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "codex is not authenticated"}
return {"status": "not-ready", "reason": "opencode provider is not connected"}
return {"status": "ready"}
+16 -48
View File
@@ -5,12 +5,10 @@ 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
from agentci.domain.models import CommandEvent
router = APIRouter()
log = logging.getLogger(__name__)
@@ -54,7 +52,7 @@ async def webhook(request: Request) -> Response:
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:
if event is None or event.requester.casefold() == container.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(container, event)
@@ -69,55 +67,25 @@ 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)
if not event.delivery_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
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})
result = await container.state_machine.receive(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.state.id})
return Response(status_code=status.HTTP_200_OK)
log.info(
"agent job queued",
extra={**extra, "job_id": job.id, "stage": job.kind.value},
"agent command persisted",
extra={
**extra,
"job_id": result.state.id,
"receive_sequence": result.state.receive_sequence,
},
)
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)
+3 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
@@ -37,7 +37,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
finally:
log.info("service shutdown started", extra={"operation": "service.shutdown"})
stop.set()
worker_task.cancel()
try:
with suppress(asyncio.CancelledError):
await worker_task
finally:
await container.close()
+27 -11
View File
@@ -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,48 @@ 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
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 +87,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"
+23 -16
View File
@@ -4,13 +4,14 @@ 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.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.prompts import PromptLibrary
from agentci.state_machine import StateMachine
from agentci.worker import Worker
from agentci.workflows.common import Dependencies
from agentci.workflows.context import ContextBuilder
@@ -24,11 +25,13 @@ class Container:
storage: Storage
gitea: GiteaClient
git: GitClient
codex: CodexClient
opencode: OpenCodeClient
state_machine: StateMachine
worker: Worker
async def close(self) -> None:
log.info("container shutdown started", extra={"operation": "container.close"})
await self.opencode.close()
await self.gitea.close()
log.info("container shutdown completed", extra={"operation": "container.close"})
@@ -38,9 +41,9 @@ async def build_container(settings: Settings) -> Container:
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()
state_machine = StateMachine(storage)
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
git = GitClient(
gitea_url=settings.gitea_url,
@@ -50,18 +53,19 @@ async def build_container(settings: Settings) -> Container:
commit_name=settings.bot_name,
commit_email=settings.bot_email,
)
codex = CodexClient(
codex_home=settings.codex_home,
opencode = OpenCodeClient(
base_url=settings.opencode_url,
username=settings.opencode_server_username,
password=settings.opencode_server_password,
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
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),
),
tools_bin=settings.dev_tools_dir / "bin",
timeout_seconds=settings.turn_timeout_seconds,
)
prompts = PromptLibrary()
context = ContextBuilder(gitea, storage)
@@ -78,7 +82,7 @@ async def build_container(settings: Settings) -> Container:
storage=storage,
gitea=gitea,
git=git,
codex=codex,
opencode=opencode,
prompts=prompts,
context=context,
development=development,
@@ -86,11 +90,14 @@ async def build_container(settings: Settings) -> Container:
dispatcher = Dispatcher(dependencies)
worker = Worker(
storage=storage,
state_machine=state_machine,
gitea=gitea,
codex=codex,
opencode=opencode,
dispatcher=dispatcher,
poll_seconds=settings.worker_poll_seconds,
workspaces_dir=settings.workspaces_dir,
bot_username=settings.bot_username,
)
container = Container(settings, storage, gitea, git, codex, worker)
container = Container(settings, storage, gitea, git, opencode, state_machine, worker)
log.info("container initialization completed", extra={"operation": "container.build"})
return container
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
from agentci.domain.models 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"),
]
+3
View File
@@ -25,6 +25,7 @@ class JobKind(StrEnum):
class JobStatus(StrEnum):
RECEIVED = "received"
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
@@ -125,6 +126,7 @@ class Job:
status: JobStatus = JobStatus.QUEUED
stage: str = "queued"
accepted_comment_id: int | None = None
runtime_session_id: str | None = None
@dataclass
@@ -136,6 +138,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
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import JobKind, JobStatus
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Notification:
listener: str
queue: str = "control"
@dataclass(frozen=True)
class JobState:
id: str
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None
runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass(frozen=True)
class Transition:
state: JobState
notifications: tuple[Notification, ...] = ()
RECONCILE = Notification("reconcile_comment")
def next_state(state: JobState | None, event: JobEvent) -> Transition:
if state is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
created = JobState(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
return Transition(created, (Notification("authorize"),))
if event.job_id != state.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(state, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if state.status is not JobStatus.RUNNING:
return Transition(state)
failed = replace(
state,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
listeners = [Notification("abort_sessions"), RECONCILE]
if state.workflow_id:
listeners.insert(1, Notification("fail_workflow"))
return Transition(failed, tuple(listeners))
if state.status is JobStatus.RECEIVED:
return _received(state, event)
if state.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(state, status=JobStatus.RUNNING, stage="starting"), (RECONCILE,)
)
if state.status is JobStatus.RUNNING:
return _running(state, event)
raise InvalidTransition(f"{event.type} is invalid while job is {state.status}")
def _received(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(state.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=state.is_pull_request)
except CommandError as exc:
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
state,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
return Transition(queued, (Notification("execute", "jobs"), RECONCILE))
def _running(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(state, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(state, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(state, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(state, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
state,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
listeners = [RECONCILE]
if state.workflow_id:
listeners.append(Notification("fail_workflow"))
return Transition(replace(state, status=status, stage=stage, error=error), tuple(listeners))
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(state: JobState) -> str:
marker = f"<!-- agentci:job id={state.id} -->"
if state.status is JobStatus.SUCCEEDED and state.comment_body:
body = state.comment_body
elif state.status is JobStatus.REJECTED:
body = f"Agent job `{state.id}` was rejected: {state.error}"
elif state.status is JobStatus.FAILED:
body = f"Agent job `{state.id}` failed during `{state.stage}`: {state.error}"
else:
kind = state.kind.value if state.kind else "command"
body = f"Agent job `{state.id}` {state.status.value} (`{kind}`; stage: `{state.stage}`)."
return f"{marker}\n{body}"
+2
View File
@@ -49,3 +49,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)
@@ -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;
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from agentci.domain.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import Workflow
from agentci.state_machine import StateMachine
class NullReporter:
final_body: str | None = None
async def progress(self, _stage: str) -> None:
pass
async def create_workflow(self, _workflow: Workflow, _stage: str) -> None:
pass
async def link_workflow(self, _workflow_id: str, _stage: str) -> None:
pass
async def link_runtime_session(self, _session_id: str) -> None:
pass
def finish(self, body: str) -> None:
self.final_body = body
_current: ContextVar[JobReporter | NullReporter | None] = ContextVar(
"job_reporter", default=None
)
class JobReporter:
def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None:
self.host = host
self.job_id = job_id
self.task_id = task_id
self.sequence = 0
self.final_body: str | None = None
async def progress(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_runtime_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
def finish(self, body: str) -> None:
self.final_body = body
async def _emit(self, event) -> None:
self.sequence += 1
await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event)
def bind_reporter(
reporter: JobReporter,
) -> Token[JobReporter | NullReporter | None]:
return _current.set(reporter)
def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None:
_current.reset(token)
def reporter() -> JobReporter | NullReporter:
return _current.get() or NullReporter()
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID, uuid5
from agentci.adapters.job_store import EvolveResult, JobStore
from agentci.domain.events import JobEvent
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ReceiveResult:
state: JobState
duplicate: bool
class StateMachine:
def __init__(self, store: JobStore) -> None:
self.store = store
async def receive(self, incoming: CommandEvent) -> ReceiveResult:
job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id))
result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming)
return ReceiveResult(result.state, result.duplicate)
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
return await self.store.evolve(event_id, event)
async def get(self, job_id: str) -> JobState | None:
return await self.store.get_job_state(job_id)
+162 -105
View File
@@ -3,11 +3,28 @@ from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from pathlib import Path
from agentci.adapters.codex import CodexClient
from agentci.adapters.gitea import GiteaClient
from agentci.adapters.job_store import ListenerTask
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.domain.models import Job, JobStatus
from agentci.domain.events import (
CommentLinked,
JobCompleted,
JobFailed,
JobStarted,
PermissionDenied,
PermissionGranted,
ServiceRestarted,
)
from agentci.domain.events import (
JobRejected as RejectedEvent,
)
from agentci.domain.models import JobStatus
from agentci.domain.state_machine import JobState, render_job_comment
from agentci.reporting import JobReporter, bind_reporter, reset_reporter
from agentci.state_machine import StateMachine
from agentci.workflows.common import JobRejected
from agentci.workflows.dispatcher import Dispatcher
@@ -19,134 +36,174 @@ class Worker:
self,
*,
storage: Storage,
state_machine: StateMachine,
gitea: GiteaClient,
codex: CodexClient,
opencode: OpenCodeClient,
dispatcher: Dispatcher,
poll_seconds: float,
workspaces_dir: Path,
bot_username: str,
) -> None:
self.storage = storage
self.host = state_machine
self.gitea = gitea
self.codex = codex
self.opencode = opencode
self.dispatcher = dispatcher
self.poll_seconds = poll_seconds
self.workspaces_dir = workspaces_dir
self.bot_username = bot_username
async def run(self, stop: asyncio.Event) -> None:
log.info("worker started", extra={"operation": "worker.run"})
await self._report_interrupted()
try:
await self._recover()
await asyncio.gather(self._loop("control", stop), self._loop("jobs", stop))
async def _loop(self, queue: str, stop: asyncio.Event) -> None:
while not stop.is_set():
if not await self.codex.login_ready():
log.warning(
"worker waiting for Codex authentication",
extra={"operation": "worker.poll"},
)
if queue == "jobs" and not await self.opencode.ready():
await self._wait(stop)
continue
job = await self.storage.claim_next()
if job is None:
task = await self.storage.claim_task(queue)
if task is None:
await self._wait(stop)
continue
await self._run_job(job)
except Exception:
log.exception("worker stopped unexpectedly", extra={"operation": "worker.run"})
try:
await self._handle(task)
except asyncio.CancelledError:
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)
log.exception(
"listener failed",
extra={"task_id": task.id, "listener": task.listener, "queue": queue},
)
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})
await self.storage.retry_task(task.id, task.attempts, _safe_error(exc))
else:
await self._safe_update_job(
job, status=JobStatus.SUCCEEDED, stage="completed"
)
log.info("job completed", extra=extra)
await self.storage.complete_task(task.id)
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 _handle(self, task: ListenerTask) -> None:
state = await self.host.get(task.job_id)
if state is None:
return
if task.listener == "authorize":
await self._authorize(task, state)
elif task.listener == "execute":
await self._execute(task, state)
elif task.listener == "reconcile_comment":
await self._reconcile(task, state)
elif task.listener == "fail_workflow":
await self.storage.fail_job_workflow(state.id)
elif task.listener == "abort_sessions":
await self._abort_job_sessions(state)
else:
raise RuntimeError(f"Unknown listener {task.listener}")
async def _safe_comment(self, job: Job, body: str) -> None:
async def _authorize(self, task: ListenerTask, state: JobState) -> None:
if state.status is not JobStatus.RECEIVED:
return
permitted = await self.gitea.has_write_permission(
state.repo_owner, state.repo_name, state.requester
)
event = (
PermissionGranted(job_id=state.id)
if permitted
else PermissionDenied(job_id=state.id)
)
outcome = "permission-granted" if permitted else "permission-denied"
await self.host.evolve(f"task:{task.id}:{outcome}", event)
async def _execute(self, task: ListenerTask, state: JobState) -> None:
if state.status is JobStatus.RUNNING:
await self.host.evolve(
f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id)
)
return
if state.status is not JobStatus.QUEUED:
return
result = await self.host.evolve(
f"task:{task.id}:started", JobStarted(job_id=state.id)
)
running = result.state
reporter = JobReporter(self.host, state.id, task.id)
token = bind_reporter(reporter)
try:
await self.gitea.create_comment(
job.repo_owner, job.repo_name, job.issue_number, body
await self.dispatcher.dispatch(running)
except JobRejected as exc:
await self.host.evolve(
f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc))
)
except Exception:
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 as exc:
latest = await self.host.get(state.id)
stage = latest.stage if latest else running.stage
await self.host.evolve(
f"task:{task.id}:failed",
JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage),
)
except Exception:
log.exception("could not persist job status", extra={"job_id": job.id})
else:
await self.host.evolve(
f"task:{task.id}:completed",
JobCompleted(
job_id=state.id,
comment_body=reporter.final_body or "Agent job completed.",
),
)
finally:
reset_reporter(token)
async def _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 _reconcile(self, task: ListenerTask, state: JobState) -> None:
latest = await self.host.get(state.id)
if latest is None:
return
body = render_job_comment(latest)
comment_id = latest.accepted_comment_id
if comment_id is not None and await self.gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
):
return
marker = f"<!-- agentci:job id={latest.id} -->"
matches = sorted(
comment.id
for comment in await self.gitea.issue_comments(
latest.repo_owner, latest.repo_name, latest.issue_number
)
if comment.body.startswith(marker)
and comment.author.casefold() == self.bot_username.casefold()
)
if matches:
comment_id = matches[0]
else:
comment_id = await self.gitea.create_comment(
latest.repo_owner, latest.repo_name, latest.issue_number, body
)
await self.host.evolve(
f"task:{task.id}:comment:{comment_id}",
CommentLinked(job_id=latest.id, comment_id=comment_id),
)
await self.gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
)
async def _recover(self) -> None:
await self.storage.recover_tasks()
for state in await self.storage.running_job_states():
await self.host.evolve(
f"recovery:{state.id}:service-restarted",
ServiceRestarted(job_id=state.id),
)
async def _abort_job_sessions(self, state: JobState) -> None:
sessions: set[tuple[str, Path]] = set()
workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None
if workflow:
sessions.update(
(session, workflow.workspace_path)
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
if session
)
elif state.runtime_session_id:
sessions.add(
(state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo")
)
for session, workspace in sessions:
await self.opencode.abort(session, workspace)
async def _wait(self, stop: asyncio.Event) -> None:
with suppress(TimeoutError):
+1 -2
View File
@@ -1,2 +1 @@
"""Codex workflow orchestration."""
"""OpenCode workflow orchestration."""
+5 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from agentci.domain.models import AgentResult, Job
from agentci.reporting import reporter
from agentci.workflows.common import Dependencies, JobRejected
@@ -20,14 +21,14 @@ class ChangeSet:
set_upstream: bool,
commit_prefix: str,
) -> str:
await self.deps.storage.update_job(job.id, stage="validating changes")
await reporter().progress("validating changes")
if not await self.deps.git.has_changes(workspace):
raise JobRejected("Codex completed without producing any file changes.")
raise JobRejected("OpenCode 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")
await reporter().progress("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 reporter().progress("pushing changes")
await self.deps.git.push(workspace, branch, set_upstream=set_upstream)
return sha
@@ -54,4 +55,3 @@ def _commit_title(markdown: str) -> str:
if value:
return value[:72]
return "apply requested changes"
+23 -22
View File
@@ -1,7 +1,13 @@
from __future__ import annotations
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
from agentci.workflows.common import Dependencies, report_for_prompt, report_json
from agentci.reporting import reporter
from agentci.workflows.common import (
Dependencies,
report_for_prompt,
report_json,
required_session,
)
class CodeReviewLoop:
@@ -18,10 +24,9 @@ class CodeReviewLoop:
) -> 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}",
await reporter().progress(
f"reviewing implementation {round_index + 1}/"
f"{self.deps.settings.implement_review_rounds}"
)
report = await self.once(
workflow,
@@ -44,12 +49,11 @@ class CodeReviewLoop:
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),
result = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
@@ -71,30 +75,27 @@ class CodeReviewLoop:
pull_context=pull_context,
)
if workflow.reviewer_session_id:
return await self.deps.codex.resume(
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-review",
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id, report = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(
workflow.workspace_path, "implementation-review"
)
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-review",
variant=self.deps.settings.implement_variant,
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
+13 -2
View File
@@ -3,14 +3,15 @@ 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.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.config import Settings
from agentci.domain.models import ReviewReport
from agentci.prompts import PromptLibrary
from agentci.reporting import reporter
from agentci.workflows.context import ContextBuilder
@@ -18,13 +19,19 @@ class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester."""
def required_session(value: str | None) -> str:
if value is None:
raise RuntimeError("Expected a persisted OpenCode session ID")
return value
@dataclass(frozen=True)
class Dependencies:
settings: Settings
storage: Storage
gitea: GiteaClient
git: GitClient
codex: CodexClient
opencode: OpenCodeClient
prompts: PromptLibrary
context: ContextBuilder
development: DevelopmentEnvironment
@@ -63,3 +70,7 @@ def report_for_prompt(report_json_value: str | None) -> str:
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
async def finish_job(body: str) -> None:
reporter().finish(body)
+7 -3
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import logging
from typing import Any
from agentci.domain.models import Job, JobKind
from agentci.domain.models import JobKind
from agentci.domain.state_machine import JobState
from agentci.workflows.common import Dependencies
from agentci.workflows.implement import ImplementWorkflow
from agentci.workflows.plan import PlanWorkflow
@@ -15,7 +17,7 @@ class Dispatcher:
def __init__(self, dependencies: Dependencies) -> None:
plan = PlanWorkflow(dependencies)
pull_request = PullRequestWorkflow(dependencies)
self.handlers = {
self.handlers: dict[JobKind, Any] = {
JobKind.PLAN: plan.plan,
JobKind.DISCUSS: plan.discuss,
JobKind.ITERATE_PLAN: plan.iterate,
@@ -24,7 +26,9 @@ class Dispatcher:
JobKind.FIX: pull_request.fix,
}
async def dispatch(self, job: Job) -> None:
async def dispatch(self, job: JobState) -> None:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
+16 -23
View File
@@ -9,12 +9,14 @@ from agentci.domain.models import (
WorkflowKind,
WorkflowStatus,
)
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment
from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import (
Dependencies,
JobRejected,
agent_comment,
finish_job,
report_json,
review_markdown,
)
@@ -36,7 +38,7 @@ class ImplementWorkflow:
f"{workflow_id[:8]}"
)
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
await self.deps.storage.update_job(job.id, stage="cloning")
await reporter().progress("cloning")
base_sha = await self.deps.git.clone(
job.repo_owner,
job.repo_name,
@@ -54,15 +56,9 @@ class ImplementWorkflow:
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 reporter().create_workflow(workflow, "installing development environment")
await self.deps.development.prepare(workspace)
await self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="implementing"
)
await reporter().progress("implementing")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
@@ -76,16 +72,19 @@ class ImplementWorkflow:
request=job.message or "(no additional request)",
development_environment=self.deps.development.description,
)
session_id, result = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workspace, "implementation")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await reporter().link_runtime_session(session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
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(
@@ -103,7 +102,7 @@ class ImplementWorkflow:
set_upstream=True,
commit_prefix="agent",
)
await self.deps.storage.update_job(job.id, stage="creating pull request")
await reporter().progress("creating pull request")
pull = await self.deps.gitea.create_pull_request(
job.repo_owner,
job.repo_name,
@@ -122,17 +121,11 @@ class ImplementWorkflow:
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),
)
body = 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
)
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def _reject_duplicate(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows(
+42 -54
View File
@@ -11,12 +11,15 @@ from agentci.domain.models import (
WorkflowKind,
WorkflowStatus,
)
from agentci.reporting import reporter
from agentci.workflows.common import (
Dependencies,
JobRejected,
agent_comment,
finish_job,
report_for_prompt,
report_json,
required_session,
review_markdown,
)
@@ -29,7 +32,7 @@ class PlanWorkflow:
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")
await reporter().progress("cloning")
base_sha = await self.deps.git.clone(
job.repo_owner,
job.repo_name,
@@ -45,9 +48,7 @@ class PlanWorkflow:
workspace_path=workspace,
base_sha=base_sha,
)
await self.deps.storage.create_workflow(workflow)
job.workflow_id = workflow.id
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="planning")
await reporter().create_workflow(workflow, "planning")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
@@ -56,16 +57,19 @@ class PlanWorkflow:
context=context,
request=job.message or "(no additional request)",
)
session_id, artifact = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workspace, "plan")
workflow.primary_session_id = session_id
await self.deps.storage.update_workflow(workflow)
await reporter().link_runtime_session(session_id)
artifact = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
schema_name="plan.json",
result_type=PlanArtifact,
)
workflow.primary_session_id = session_id
workflow.artifact = artifact.plan_markdown
await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact)
@@ -73,39 +77,38 @@ class PlanWorkflow:
async def discuss(self, job: Job) -> None:
workflow = await self._latest_plan(job)
if workflow.runtime != "opencode":
raise JobRejected(
"The latest plan predates OpenCode and cannot be resumed; "
"start a new `/agent plan`."
)
if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing")
await reporter().link_workflow(workflow.id, "discussing")
prompt = self.deps.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message
)
reply = await self.deps.codex.resume(
reply = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="discussion.json",
result_type=DiscussionReply,
)
await self.deps.gitea.create_comment(
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("discussion", workflow.id, reply.markdown),
)
await finish_job(agent_comment("discussion", workflow.id, reply.markdown))
async def iterate(self, job: Job) -> None:
await self._reject_if_active_or_merged_pr(job)
workflow = await self._latest_plan(job)
if workflow.runtime != "opencode":
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact:
raise JobRejected("The latest plan has no saved artifact.")
await self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="iterating plan"
)
await reporter().link_workflow(workflow.id, "iterating plan")
context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number
)
@@ -116,12 +119,11 @@ class PlanWorkflow:
review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)",
)
artifact = await self.deps.codex.resume(
artifact = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
@@ -134,10 +136,8 @@ class PlanWorkflow:
) -> 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}",
await reporter().progress(
f"reviewing plan {round_index + 1}/{self.deps.settings.plan_review_rounds}"
)
report = await self._review(workflow, context, artifact)
workflow.artifact = artifact.plan_markdown
@@ -152,12 +152,11 @@ class PlanWorkflow:
artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json),
)
artifact = await self.deps.codex.resume(
session_id=_required(workflow.primary_session_id),
artifact = await self.deps.opencode.resume(
session_id=required_session(workflow.primary_session_id),
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-read",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="plan.json",
result_type=PlanArtifact,
@@ -171,26 +170,27 @@ class PlanWorkflow:
"plan_review", context=context, artifact=artifact.plan_markdown
)
if workflow.reviewer_session_id:
return await self.deps.codex.resume(
return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-review",
variant=self.deps.settings.plan_variant,
workspace=workflow.workspace_path,
schema_name="review.json",
result_type=ReviewReport,
)
session_id, report = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review")
workflow.reviewer_session_id = session_id
await self.deps.storage.update_workflow(workflow)
report = await self.deps.opencode.resume(
session_id=session_id,
workspace=workflow.workspace_path,
prompt=prompt,
model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning,
permission="agentci-review",
variant=self.deps.settings.plan_variant,
schema_name="review.json",
result_type=ReviewReport,
)
workflow.reviewer_session_id = session_id
return report
async def _finish(
@@ -204,17 +204,11 @@ class PlanWorkflow:
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),
)
body = 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
)
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def _latest_plan(self, job: Job) -> Workflow:
workflow = await self.deps.storage.latest_workflow(
@@ -239,9 +233,3 @@ class PlanWorkflow:
f"Issue plan iteration is disabled because agent PR #{pull.number} "
"is open or merged. Iterate an open implementation on its PR."
)
def _required(value: str | None) -> str:
if value is None: # Defensive: workflows with missing sessions are rejected earlier.
raise RuntimeError("Expected a persisted Codex session ID")
return value
+22 -33
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, result_comment
from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import (
Dependencies,
JobRejected,
agent_comment,
finish_job,
report_for_prompt,
report_json,
review_markdown,
@@ -28,6 +30,8 @@ class PullRequestWorkflow:
raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`."
)
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.")
pull, context = await self.deps.context.pull_request_context(
@@ -37,16 +41,11 @@ class PullRequestWorkflow:
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 reporter().link_workflow(workflow.id, "synchronizing branch")
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 reporter().progress("installing development environment")
await self.deps.development.prepare(workflow.workspace_path)
await self.deps.storage.update_job(job.id, stage="implementing iteration")
await reporter().progress("implementing iteration")
prompt = self.deps.prompts.render(
"implementation_iterate",
context=context,
@@ -54,12 +53,11 @@ class PullRequestWorkflow:
message=job.message or "(perform one additional reviewed refinement)",
development_environment=self.deps.development.description,
)
result = await self.deps.codex.resume(
result = await self.deps.opencode.resume(
session_id=workflow.primary_session_id,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
workspace=workflow.workspace_path,
schema_name="agent_result.json",
result_type=AgentResult,
@@ -87,17 +85,13 @@ class PullRequestWorkflow:
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)),
body = 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
)
body = f"{body}\n\n{remaining}"
await finish_job(body)
async def fix(self, job: Job) -> None:
pull_number = _pull_number(job)
@@ -107,16 +101,14 @@ class PullRequestWorkflow:
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 reporter().progress("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 reporter().progress("installing development environment")
await self.deps.development.prepare(workspace)
prompt = self.deps.prompts.render(
"fix",
@@ -124,13 +116,15 @@ class PullRequestWorkflow:
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(
await reporter().progress("fixing")
session_id = await self.deps.opencode.create_session(workspace, "fix")
await reporter().link_runtime_session(session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning,
permission="agentci-write",
variant=self.deps.settings.implement_variant,
schema_name="agent_result.json",
result_type=AgentResult,
)
@@ -142,12 +136,7 @@ class PullRequestWorkflow:
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 finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha)))
def _pull_number(job: Job) -> int:
+13 -14
View File
@@ -28,15 +28,14 @@ def serious_report() -> ReviewReport:
)
class FakeCodex:
class FakeOpenCode:
def __init__(self, reports: list[ReviewReport]) -> None:
self.reports = iter(reports)
self.reviews = 0
self.revisions = 0
async def start(self, **_kwargs):
self.reviews += 1
return "reviewer", next(self.reports)
async def create_session(self, *_args):
return "reviewer"
async def resume(self, **kwargs):
if kwargs["result_type"] is ReviewReport:
@@ -60,15 +59,15 @@ class FakePrompts:
def objects(rounds: int, reports: list[ReviewReport]):
codex = FakeCodex(reports)
opencode = FakeOpenCode(reports)
settings = SimpleNamespace(
implement_review_rounds=rounds,
implement_model="model",
implement_reasoning="high",
implement_variant="high",
)
deps = SimpleNamespace(
settings=settings,
codex=codex,
opencode=opencode,
storage=FakeStorage(),
prompts=FakePrompts(),
development=SimpleNamespace(description="python"),
@@ -95,12 +94,12 @@ def objects(rounds: int, reports: list[ReviewReport]):
message="",
comment_id=1,
)
return CodeReviewLoop(deps), codex, workflow, job # type: ignore[arg-type]
return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type]
async def test_stops_after_clean_second_review() -> None:
clean = ReviewReport(summary="Ready", findings=[])
loop, codex, workflow, job = objects(4, [serious_report(), clean])
loop, opencode, workflow, job = objects(4, [serious_report(), clean])
_, report = await loop.run(
job,
workflow,
@@ -109,12 +108,12 @@ async def test_stops_after_clean_second_review() -> None:
AgentResult(summary_markdown="initial", tests=[]),
)
assert not report.has_serious_findings
assert codex.reviews == 2
assert codex.revisions == 1
assert opencode.reviews == 2
assert opencode.revisions == 1
async def test_does_not_make_unreviewed_final_revision() -> None:
loop, codex, workflow, job = objects(
loop, opencode, workflow, job = objects(
3, [serious_report(), serious_report(), serious_report()]
)
_, report = await loop.run(
@@ -125,5 +124,5 @@ async def test_does_not_make_unreviewed_final_revision() -> None:
AgentResult(summary_markdown="initial", tests=[]),
)
assert report.has_serious_findings
assert codex.reviews == 3
assert codex.revisions == 2
assert opencode.reviews == 3
assert opencode.revisions == 2
-138
View File
@@ -1,138 +0,0 @@
import asyncio
import tomllib
from pathlib import Path
from agentci.adapters.codex import CodexClient, _session_id
from agentci.domain.models import AgentResult
def test_enables_codegraph_in_shared_codex_config() -> None:
root = Path(__file__).parents[1]
config = tomllib.loads((root / "codex" / "config.toml").read_text())
codegraph = config["mcp_servers"]["codegraph"]
assert codegraph["command"] == "codegraph"
assert codegraph["args"] == ["serve", "--mcp"]
assert codegraph["env"]["CODEGRAPH_TELEMETRY"] == "0"
def test_compose_allows_nested_codex_procfs() -> None:
root = Path(__file__).parents[1]
compose = (root / "compose.yaml").read_text()
assert "systempaths=unconfined" in compose
def test_extracts_thread_id_from_jsonl() -> None:
output = '\n'.join(
[
'{"type":"turn.started"}',
'{"type":"thread.started","thread_id":"abc-123"}',
"not json",
]
)
assert _session_id(output) == "abc-123"
def test_missing_thread_id_returns_none() -> None:
assert _session_id('{"type":"turn.completed"}') is None
def test_turns_skip_interactive_git_trust_check(tmp_path) -> None:
client = CodexClient(
codex_home=tmp_path / "codex",
schemas_dir=tmp_path / "schemas",
timeout_seconds=60,
research_model="gpt-5.6-luna",
research_reasoning="high",
context7_api_key=None,
)
args = client._turn_args("model", "medium", "agentci-read", "plan.json")
assert "--skip-git-repo-check" in args
def test_configures_research_agent_and_optional_context7_key(tmp_path) -> None:
client = CodexClient(
codex_home=tmp_path / "codex",
schemas_dir=tmp_path / "schemas",
timeout_seconds=60,
research_model="research-model",
research_reasoning="high",
context7_api_key="ctx7-secret",
)
agent = (tmp_path / "codex" / "agents" / "research.toml").read_text()
parsed = tomllib.loads(agent)
assert 'name = "research"' in agent
assert parsed["model"] == "research-model"
assert parsed["model_reasoning_effort"] == "high"
assert parsed["web_search"] == "live"
assert parsed["mcp_servers"]["codegraph"]["enabled"] is False
assert 'url = "https://mcp.context7.com/mcp"' in agent
assert 'url = "https://mcp.grep.app"' in agent
assert "ctx7-secret" not in agent
assert client._environment()["CONTEXT7_API_KEY"] == "ctx7-secret"
def test_exposes_development_tools_without_service_secrets(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("PATH", "/usr/bin")
monkeypatch.setenv("AGENTCI_PRIVATE_VALUE", "secret")
tools_bin = tmp_path / "tools" / "bin"
client = CodexClient(
codex_home=tmp_path / "codex",
schemas_dir=tmp_path / "schemas",
timeout_seconds=60,
research_model="gpt-5.6-luna",
research_reasoning="high",
context7_api_key=None,
tools_bin=tools_bin,
)
environment = client._environment()
assert environment["PATH"] == f"{tools_bin}:/usr/bin"
assert "AGENTCI_PRIVATE_VALUE" not in environment
async def test_invokes_codex_from_workflow_workspace(tmp_path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
captured: dict[str, object] = {}
class Process:
returncode = 0
async def communicate(self, prompt: bytes) -> tuple[bytes, bytes]:
assert prompt == b"prompt"
return b'{"type":"thread.started","thread_id":"thread"}', b""
async def create_subprocess_exec(*args, **kwargs):
captured["cwd"] = kwargs["cwd"]
output = Path(args[args.index("--output-last-message") + 1])
output.write_text( # noqa: ASYNC240 - tiny test-owned result file
'{"summary_markdown":"summary","tests":[]}'
)
return Process()
monkeypatch.setattr(asyncio, "create_subprocess_exec", create_subprocess_exec)
client = CodexClient(
codex_home=tmp_path / "codex",
schemas_dir=tmp_path / "schemas",
timeout_seconds=60,
research_model="gpt-5.6-luna",
research_reasoning="high",
context7_api_key=None,
)
_, result = await client._invoke(
["codex", "exec", "-"],
"prompt",
AgentResult,
workspace=workspace,
)
assert captured["cwd"] == workspace
assert result.summary_markdown == "summary"
+20
View File
@@ -27,9 +27,29 @@ def test_empty_install_scripts_disable_setup() -> None:
assert settings.install_scripts == []
def test_defaults_research_variant_to_high(monkeypatch) -> None:
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.research_variant == "high"
def test_defaults_explore_agent_to_luna_low() -> None:
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.explore_model == "openai/gpt-5.6-luna"
assert settings.explore_variant == "low"
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.install_scripts == ["python", "dotnet"]
@pytest.mark.parametrize(
"field", ["plan_model", "implement_model", "explore_model", "research_model"]
)
def test_requires_provider_qualified_opencode_models(field: str) -> None:
with pytest.raises(ValidationError, match="provider/model"):
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg]
+22
View File
@@ -0,0 +1,22 @@
import json
import httpx
import respx
from agentci.adapters.gitea import GiteaClient
@respx.mock
async def test_updates_issue_comment_by_id() -> None:
route = respx.patch(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
).mock(return_value=httpx.Response(200, json={"id": 17}))
client = GiteaClient("https://gitea.example", "secret")
try:
await client.update_comment("org", "repo", 17, "updated status")
finally:
await client.close()
assert route.called
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
+5 -5
View File
@@ -1,12 +1,12 @@
from pathlib import Path
def test_dotnet_wrapper_uses_sandbox_writable_runtime_directories() -> None:
def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None:
root = Path(__file__).parents[1]
script = (root / "install-scripts" / "dotnet").read_text()
assert 'DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' in script
assert 'NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' in script
http_cache = 'NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"'
assert http_cache in script
assert "export DOTNET_ROOT=" in script
assert "/tmp/agentci-dotnet" not in script
assert "DEV_TOOLS_DIR/runtime/dotnet" in script
assert "NUGET_PACKAGES" in script
assert 'export HOME="$DOTNET_CLI_HOME"' in script
+10
View File
@@ -0,0 +1,10 @@
import logging
from agentci.logging import configure_logging
def test_suppresses_http_client_request_logs() -> None:
configure_logging()
assert logging.getLogger("httpx").level == logging.WARNING
assert logging.getLogger("httpcore").level == logging.WARNING
+238
View File
@@ -0,0 +1,238 @@
import asyncio
import json
from pathlib import Path
import httpx
import pytest
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
from agentci.domain.models import AgentResult
API_DOCUMENT = {
"paths": {
"/global/health": {"get": {}},
"/provider": {"get": {}},
"/session": {"post": {}},
"/session/{sessionID}/message": {"post": {}},
"/session/{sessionID}/abort": {"post": {}},
}
}
PROVIDERS = {
"connected": ["openai"],
"all": [
{
"id": "openai",
"models": {
"model": {
"status": "active",
"capabilities": {"toolcall": True},
"variants": {"high": {}},
}
},
}
],
}
class FakeCodeGraph:
def __init__(self) -> None:
self.prepared: list[Path] = []
async def prepare(self, workspace: Path) -> None:
self.prepared.append(workspace)
def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCodeClient:
selected_codegraph = codegraph or FakeCodeGraph()
return OpenCodeClient(
base_url="http://opencode:4096",
username="opencode",
password="server-secret",
schemas_dir=Path(__file__).parents[1] / "src" / "agentci" / "prompts" / "schemas",
health_directory=tmp_path,
required_models=(("openai/model", None),),
timeout_seconds=60,
codegraph=selected_codegraph, # type: ignore[arg-type]
transport=httpx.MockTransport(handler),
)
async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Path) -> None:
expected_directory = str(tmp_path)
async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["authorization"].startswith("Basic ")
if request.url.path == "/global/health":
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
if request.url.path == "/doc":
return httpx.Response(200, json=API_DOCUMENT)
assert request.headers["x-opencode-directory"] == expected_directory
return httpx.Response(200, json=PROVIDERS)
value = client(tmp_path, handler)
assert await value.ready()
await value.close()
async def test_ready_rejects_missing_provider(tmp_path: Path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/global/health":
return httpx.Response(200, json={"healthy": True, "version": "1.18.4"})
if request.url.path == "/doc":
return httpx.Response(200, json=API_DOCUMENT)
return httpx.Response(200, json={**PROVIDERS, "connected": ["anthropic"]})
value = client(tmp_path, handler)
assert not await value.ready()
await value.close()
async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
codegraph = FakeCodeGraph()
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert request.headers["x-opencode-directory"] == str(workspace.resolve())
if request.url.path == "/session":
return httpx.Response(200, json={"id": "ses_new"})
body = json.loads(request.content)
assert body["model"] == {"providerID": "openai", "modelID": "model"}
assert body["agent"] == "build"
assert body["variant"] == "high"
assert body["format"]["type"] == "json_schema"
assert body["format"]["schema"]["required"] == ["summary_markdown", "tests"]
return httpx.Response(
200,
json={
"info": {
"role": "assistant",
"sessionID": "ses_new",
"structured": {"summary_markdown": "summary", "tests": []},
},
"parts": [],
},
)
value = client(tmp_path, handler, codegraph)
session_id, result = await value.start(
workspace=workspace,
prompt="implement",
model="openai/model",
variant="high",
schema_name="agent_result.json",
result_type=AgentResult,
)
assert session_id == "ses_new"
assert result.summary_markdown == "summary"
assert codegraph.prepared == [workspace]
assert [request.url.path for request in requests] == [
"/session",
"/session/ses_new/message",
]
await value.close()
async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
prompts: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
prompts.append(body["parts"][0]["text"])
if len(prompts) == 1:
return httpx.Response(
200, json={"info": {"error": {"name": "StructuredOutputError"}}}
)
return httpx.Response(
200,
json={
"info": {"structured": {"summary_markdown": "fixed", "tests": []}},
"parts": [],
},
)
value = client(tmp_path, handler)
result = await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert result.summary_markdown == "fixed"
assert prompts[0] == "continue"
assert "without repeating repository work" in prompts[1]
await value.close()
async def test_aborts_timed_out_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
aborted = False
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal aborted
if request.url.path.endswith("/abort"):
aborted = True
return httpx.Response(200, json=True)
raise httpx.ReadTimeout("slow", request=request)
value = client(tmp_path, handler)
with pytest.raises(OpenCodeError, match="exceeded 60 seconds"):
await value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
assert aborted
await value.close()
async def test_aborts_cancelled_session(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
started = asyncio.Event()
aborted = False
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal aborted
if request.url.path.endswith("/abort"):
aborted = True
return httpx.Response(200, json=True)
started.set()
await asyncio.Event().wait()
raise AssertionError("unreachable")
value = client(tmp_path, handler)
turn = asyncio.create_task(
value.resume(
session_id="ses_existing",
workspace=workspace,
prompt="continue",
model="openai/model",
variant=None,
schema_name="agent_result.json",
result_type=AgentResult,
)
)
await started.wait()
turn.cancel()
with pytest.raises(asyncio.CancelledError):
await turn
assert aborted
await value.close()
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import httpx
import pytest
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
def client(tmp_path: Path, status: int) -> OpenCodeClient:
return OpenCodeClient(
base_url="http://opencode:4096",
username="opencode",
password="secret",
schemas_dir=tmp_path,
health_directory=tmp_path,
required_models=(),
timeout_seconds=60,
transport=httpx.MockTransport(lambda _request: httpx.Response(status)),
)
@pytest.mark.parametrize("status", [200, 204, 404, 409])
async def test_absent_or_inactive_session_is_success(tmp_path: Path, status: int) -> None:
value = client(tmp_path, status)
await value.abort("session", tmp_path)
await value.close()
@pytest.mark.parametrize("status", [400, 429, 500])
async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -> None:
value = client(tmp_path, status)
with pytest.raises(OpenCodeError):
await value.abort("session", tmp_path)
await value.close()
+43
View File
@@ -0,0 +1,43 @@
import json
from pathlib import Path
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
root = Path(__file__).parents[1]
config = json.loads((root / "opencode" / "opencode.json").read_text())
assert "permission" not in config
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
assert "permission" not in config["agent"]["explore"]
assert config["agent"]["research"]["permission"] == {
"*": "deny",
"websearch": "allow",
"context7_*": "allow",
"gh_grep_*": "allow",
}
assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"]
assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp"
assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}"
assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}"
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
def test_compose_removes_codex_sandbox_exceptions() -> None:
root = Path(__file__).parents[1]
compose = (root / "compose.yaml").read_text()
dockerfile = (root / "Dockerfile").read_text()
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"):
assert forbidden not in compose
assert "no_cache" not in compose
assert "opencode_home:/var/lib/opencode" in compose
assert "HOME: /etc/opencode/home" in compose
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose
assert 'OPENCODE_ENABLE_EXA: "1"' in compose
assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose
assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose
assert compose.count("/run/agentci:mode=1777") == 2
assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose
assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile
assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile
+40
View File
@@ -0,0 +1,40 @@
from agentci.adapters.opencode_support import api_contract_ready, models_ready
def test_api_contract_requires_session_message_and_abort_routes() -> None:
valid = {
"paths": {
"/global/health": {"get": {}},
"/provider": {"get": {}},
"/session": {"post": {}},
"/session/{sessionID}/message": {"post": {}},
"/session/{sessionID}/abort": {"post": {}},
}
}
assert api_contract_ready(valid)
del valid["paths"]["/session/{sessionID}/abort"]
assert not api_contract_ready(valid)
def test_model_readiness_requires_tools_and_configured_variant() -> None:
payload = {
"connected": ["openai"],
"all": [
{
"id": "openai",
"models": {
"model": {
"status": "active",
"capabilities": {"toolcall": True},
"variants": {"high": {}},
}
},
}
],
}
assert models_ready(payload, {("openai", "model", "high")})
assert not models_ready(payload, {("openai", "model", "missing")})
payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False
assert not models_ready(payload, {("openai", "model", "high")})
+75
View File
@@ -0,0 +1,75 @@
from dataclasses import FrozenInstanceError
import pytest
from agentci.domain.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobStarted,
PermissionGranted,
ServiceRestarted,
)
from agentci.domain.models import JobKind, JobStatus
from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment
def received(body: str = "/agent plan message"):
return next_state(
None,
CommandReceived(
job_id="job",
delivery_id="delivery",
receive_sequence=1,
command_body=body,
target_key="org/repo:issue:1",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=None,
requester="alice",
comment_id=4,
),
)
def test_permission_parses_and_queues_execution() -> None:
transition = next_state(received().state, PermissionGranted(job_id="job"))
assert transition.state.status is JobStatus.QUEUED
assert transition.state.kind is JobKind.PLAN
assert transition.state.message == "message"
assert [(item.listener, item.queue) for item in transition.notifications] == [
("execute", "jobs"),
("reconcile_comment", "control"),
]
def test_invalid_syntax_is_rejected_after_permission() -> None:
transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job"))
assert transition.state.status is JobStatus.REJECTED
assert "Unknown" in (transition.state.error or "")
def test_running_completion_and_restart_are_explicit() -> None:
queued = next_state(received().state, PermissionGranted(job_id="job")).state
running = next_state(queued, JobStarted(job_id="job")).state
completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state
assert completed.status is JobStatus.SUCCEEDED
assert "# Result" in render_job_comment(completed)
assert next_state(completed, ServiceRestarted(job_id="job")).state == completed
def test_comment_link_is_allowed_on_terminal_state() -> None:
queued = next_state(received().state, PermissionGranted(job_id="job")).state
running = next_state(queued, JobStarted(job_id="job")).state
completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state
linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state
assert linked.accepted_comment_id == 9
def test_state_is_immutable_and_invalid_transitions_fail() -> None:
state = received().state
with pytest.raises(FrozenInstanceError):
state.stage = "changed" # type: ignore[misc]
with pytest.raises(InvalidTransition):
next_state(state, JobStarted(job_id="job"))
+71 -51
View File
@@ -1,16 +1,17 @@
import sqlite3
from pathlib import Path
import pytest
from agentci.adapters.storage import Storage
from agentci.domain.models import (
Job,
JobKind,
JobStatus,
Workflow,
WorkflowKind,
WorkflowStatus,
from agentci.domain.events import (
JobStarted,
PermissionDenied,
PermissionGranted,
WorkflowCreated,
)
from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus
from agentci.state_machine import StateMachine
@pytest.fixture
@@ -21,40 +22,60 @@ async def storage(tmp_path: Path) -> Storage:
return value
def make_job(job_id: str = "job-1") -> Job:
return Job(
id=job_id,
kind=JobKind.PLAN,
target_key="alice/repo:issue:3",
def command(delivery: str, body: str = "/agent plan") -> CommandEvent:
return CommandEvent(
delivery_id=delivery,
comment_id=int(delivery.rsplit("-", 1)[-1]),
repo_owner="alice",
repo_name="repo",
issue_number=3,
pr_number=None,
requester="alice",
message="",
comment_id=10,
body=body,
)
async def test_enqueue_is_idempotent_and_claims_fifo(storage: Storage) -> None:
assert await storage.enqueue("delivery-1", make_job())
assert not await storage.enqueue("delivery-1", make_job("job-2"))
claimed = await storage.claim_next()
assert claimed is not None
assert claimed.id == "job-1"
assert claimed.status is JobStatus.RUNNING
assert await storage.claim_next() is None
async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None:
host = StateMachine(storage)
first = await host.receive(command("delivery-1"))
duplicate = await host.receive(command("delivery-1"))
second = await host.receive(command("delivery-2"))
assert not first.duplicate
assert duplicate.duplicate
assert duplicate.state.id == first.state.id
assert second.state.receive_sequence == first.state.receive_sequence + 1
async def test_recovers_running_job_as_failed(storage: Storage) -> None:
await storage.enqueue("delivery-1", make_job())
assert await storage.claim_next() is not None
recovered = await storage.recover_running()
assert [job.id for job in recovered] == ["job-1"]
assert await storage.claim_next() is None
async def test_received_job_blocks_later_execute_task(storage: Storage) -> None:
host = StateMachine(storage)
first = (await host.receive(command("delivery-1"))).state
second = (await host.receive(command("delivery-2"))).state
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
assert await storage.claim_task("jobs") is None
await host.evolve("deny-1", PermissionDenied(job_id=first.id))
task = await storage.claim_task("jobs")
assert task is not None
assert task.job_id == second.id
async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) -> None:
async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None:
host = StateMachine(storage)
state = (await host.receive(command("delivery-1"))).state
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
with sqlite3.connect(storage.database_path) as connection:
row = connection.execute(
"SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,)
).fetchone()
assert row is not None
assert row[0] is not None
assert row[1] is None
async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None:
workflow = Workflow(
id="workflow-1",
kind=WorkflowKind.PLAN,
@@ -67,37 +88,36 @@ async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) ->
status=WorkflowStatus.COMPLETED,
)
await storage.create_workflow(workflow)
await storage.fail_job_workflow("missing-job")
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
assert loaded is not None
assert loaded.artifact == "# Plan"
assert loaded.workspace_path == tmp_path / "repo"
assert loaded.status is WorkflowStatus.COMPLETED
async def test_tracks_operational_comments(storage: Storage) -> None:
await storage.enqueue("delivery-1", make_job())
await storage.set_job_comment("job-1", "accepted_comment_id", 21)
await storage.set_job_comment("job-1", "started_comment_id", 22)
assert await storage.operational_comment_ids("alice", "repo", 3) == {21, 22}
async def test_failed_followup_does_not_invalidate_completed_workflow(
storage: Storage, tmp_path: Path
) -> None:
async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None:
host = StateMachine(storage)
state = (await host.receive(command("delivery-1"))).state
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
workflow = Workflow(
id="workflow-1",
id="workflow-atomic",
kind=WorkflowKind.PLAN,
repo_owner="alice",
repo_name="repo",
issue_number=3,
workspace_path=tmp_path / "repo",
base_sha="abc",
status=WorkflowStatus.COMPLETED,
)
await storage.create_workflow(workflow)
job = make_job()
job.workflow_id = workflow.id
await storage.enqueue("delivery-1", job)
await storage.fail_job_workflow(job.id)
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
assert loaded is not None
assert loaded.status is WorkflowStatus.COMPLETED
result = await host.evolve(
"workflow-created",
WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
)
assert result.state.workflow_id == workflow.id
assert await storage.get_workflow(workflow.id) is not None
async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None:
with sqlite3.connect(storage.database_path) as connection:
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
assert "receive_sequence" in columns
assert "version" not in columns
+36 -76
View File
@@ -1,50 +1,29 @@
import hashlib
import hmac
import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
class FakeStorage:
def __init__(self) -> None:
self.jobs = []
self.deliveries: set[str] = set()
class FakeHost:
def __init__(self, duplicate: bool = False) -> None:
self.events = []
self.duplicate = duplicate
async def enqueue(self, delivery_id, job):
if delivery_id in self.deliveries:
return False
self.deliveries.add(delivery_id)
self.jobs.append(job)
return True
async def record_delivery(self, delivery_id, _comment_id):
if delivery_id in self.deliveries:
return False
self.deliveries.add(delivery_id)
return True
async def set_job_comment(self, *_args):
return None
class FakeGitea:
def __init__(self) -> None:
self.comments: list[str] = []
async def create_comment(self, _owner, _repo, _number, body):
self.comments.append(body)
return len(self.comments)
async def receive(self, event):
self.events.append(event)
state = SimpleNamespace(id="job", receive_sequence=1)
return SimpleNamespace(state=state, duplicate=self.duplicate)
def payload(body: str, *, is_pull: bool = False) -> dict:
value = {
"action": "created",
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
"repository": {
"name": "repo",
"owner": {"login": "org"},
},
"repository": {"name": "repo", "owner": {"login": "org"}},
"issue": {"number": 4},
"is_pull": is_pull,
}
@@ -53,59 +32,40 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
return value
def test_extracts_pull_request_event() -> None:
event = _event_from_payload("delivery", payload("/agent fix now", is_pull=True))
assert event is not None
assert event.pr_number == 4
assert event.target_key == "org/repo:pr:4"
async def test_authorized_command_is_queued() -> None:
storage = FakeStorage()
gitea = FakeGitea()
container = SimpleNamespace(storage=storage, gitea=gitea)
event = _event_from_payload("delivery", payload("/agent plan consider migrations"))
assert event is not None
response = await _handle_command(container, event)
assert response.status_code == 202
assert len(storage.jobs) == 1
assert "queued" in gitea.comments[0]
async def test_iterate_message_is_preserved_on_queued_job() -> None:
storage = FakeStorage()
container = SimpleNamespace(storage=storage, gitea=FakeGitea())
async def test_command_is_forwarded_without_parsing() -> None:
host = FakeHost()
event = _event_from_payload(
"delivery",
payload(
"/agent iterate\n\nkeep the API stable\nlimit changes to the parser",
is_pull=True,
),
"delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
)
assert event is not None
response = await _handle_command(container, event)
response = await _handle_command(SimpleNamespace(state_machine=host), event)
assert response.status_code == 202
assert len(storage.jobs) == 1
assert storage.jobs[0].message == (
"keep the API stable\nlimit changes to the parser"
)
assert host.events[0].body == "/agent iterate\n\nkeep raw body"
async def test_duplicate_returns_200() -> None:
event = _event_from_payload("delivery", payload("/agent plan"))
assert event is not None
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
assert response.status_code == 200
async def test_missing_delivery_is_rejected() -> None:
event = _event_from_payload("", payload("/agent plan"))
assert event is not None
with pytest.raises(HTTPException) as raised:
await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
assert raised.value.status_code == 400
async def test_non_command_is_ignored() -> None:
container = SimpleNamespace(storage=FakeStorage(), gitea=FakeGitea())
event = _event_from_payload("delivery", payload("ordinary discussion"))
assert event is not None
response = await _handle_command(container, event)
response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
assert response.status_code == 204
def test_rejects_bad_signature() -> None:
def test_signature_validation() -> None:
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
assert valid_signature(b"secret", b"{}", signature)
assert not valid_signature(b"secret", b"{}", "bad")
def test_accepts_valid_signature() -> None:
body = json.dumps(payload("ordinary comment")).encode()
signature = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
assert valid_signature(b"secret", body, signature)
+72
View File
@@ -0,0 +1,72 @@
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from agentci.domain.models import Workflow, WorkflowKind
from agentci.domain.state_machine import JobState
from agentci.worker import Worker, _safe_error
class FakeStorage:
def __init__(self, workflow=None) -> None:
self.workflow = workflow
async def get_workflow(self, _workflow_id):
return self.workflow
class FakeOpenCode:
def __init__(self) -> None:
self.aborted = set()
async def abort(self, session_id, workspace):
self.aborted.add((session_id, workspace))
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
return Worker(
storage=storage, # type: ignore[arg-type]
state_machine=SimpleNamespace(), # type: ignore[arg-type]
gitea=SimpleNamespace(), # type: ignore[arg-type]
opencode=opencode, # type: ignore[arg-type]
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
poll_seconds=1,
workspaces_dir=tmp_path,
bot_username="agentci",
)
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
workspace = tmp_path / "workflow" / "repo"
workflow = Workflow(
id="flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=workspace,
base_sha="base",
primary_session_id="primary",
reviewer_session_id="reviewer",
)
opencode = FakeOpenCode()
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
cast(JobState, state)
)
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
opencode = FakeOpenCode()
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
cast(JobState, state)
)
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
def test_safe_error_is_single_line_and_bounded() -> None:
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
assert "\n" not in value
assert len(value) == 1000