Author SHA1 Message Date
StanPonomarev a9289e656c fix: logs and build 2026-07-21 00:46:16 +02:00
StanPonomarev 7a1b18f931 fix: opencode config 2 2026-07-21 00:42:56 +02:00
StanPonomarev 6b857e9adb fix: opencode config 2026-07-21 00:37:11 +02:00
StanPonomarev 45858bff06 fix: restore auth and research variant 2026-07-21 00:28:19 +02:00
StanPonomarev d3946b195e fix: opencode restarting 2026-07-21 00:16:04 +02:00
StanPonomarev b47430c963 fix: remove build script 2026-07-21 00:10:58 +02:00
StanPonomarev 6f24df8cd3 feat: switch to opencode 2026-07-21 00:00:24 +02:00
46 changed files with 1331 additions and 756 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
+93 -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,92 @@ 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. Each
accepted command gets separate queued and started comments. Final plans, PR results, failures, and
remaining review findings are posted separately.
## 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 +108,30 @@ 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.
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. An in-progress job is aborted in OpenCode
and marked failed instead of being replayed because a partial model turn may already have changed
files. Git pushes are never forced.
## Development
@@ -132,7 +142,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 = [
+9 -2
View File
@@ -1,12 +1,18 @@
#!/bin/sh
set -eu
python -c '
if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then
export OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE")
fi
if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then
python -c '
import json
import os
from pathlib import Path
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea"
config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.yml"
token_path = Path(
@@ -22,5 +28,6 @@ login = {
config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
config_path.chmod(0o600)
'
fi
exec "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}"
exec /usr/local/libexec/tea "$@"
+1 -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)
+7
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(
+8 -3
View File
@@ -39,8 +39,8 @@ class JobStore(Database):
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
status, stage, runtime_session_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job.id,
@@ -56,6 +56,7 @@ class JobStore(Database):
job.workflow_id,
job.status,
job.stage,
job.runtime_session_id,
now(),
),
)
@@ -91,6 +92,7 @@ class JobStore(Database):
stage: str | None = None,
error: str | None = None,
workflow_id: str | None = None,
runtime_session_id: str | None = None,
) -> None:
updates: dict[str, object] = {}
if status is not None:
@@ -103,6 +105,8 @@ class JobStore(Database):
updates["error"] = error
if workflow_id is not None:
updates["workflow_id"] = workflow_id
if runtime_session_id is not None:
updates["runtime_session_id"] = runtime_session_id
await self._update("jobs", job_id, updates)
log.info(
"job state updated",
@@ -161,7 +165,7 @@ class JobStore(Database):
(
JobStatus.FAILED,
"interrupted",
"Service restarted during an active Codex turn",
"Service restarted during an active OpenCode turn",
now(),
JobStatus.RUNNING,
),
@@ -192,4 +196,5 @@ def job_from_row(
status=status or JobStatus(row["status"]),
stage=stage or row["stage"],
accepted_comment_id=row["accepted_comment_id"],
runtime_session_id=row["runtime_session_id"],
)
+249
View File
@@ -0,0 +1,249 @@
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)
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) -> None:
try:
await self.client.post(
f"/session/{session_id}/abort",
headers=directory_headers(workspace),
timeout=10,
)
except httpx.HTTPError:
log.exception("OpenCode session could not be aborted")
+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
+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"}
+13
View File
@@ -70,6 +70,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT)
log.info("agent command received", extra=extra)
permitted = await container.gitea.has_write_permission(
event.repo_owner, event.repo_name, event.requester
)
if not permitted:
log.warning("agent command rejected: insufficient permission", 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,
"Agent command rejected: repository write permission is required.",
)
return Response(status_code=status.HTTP_202_ACCEPTED)
try:
command = parse_command(event.body)
except CommandError as exc:
+4 -2
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,8 +37,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
finally:
log.info("service shutdown started", extra={"operation": "service.shutdown"})
stop.set()
worker_task.cancel()
try:
await worker_task
with suppress(asyncio.CancelledError):
await worker_task
finally:
await container.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
+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"
+18 -16
View File
@@ -4,10 +4,10 @@ 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
@@ -24,11 +24,12 @@ class Container:
storage: Storage
gitea: GiteaClient
git: GitClient
codex: CodexClient
opencode: OpenCodeClient
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,7 +39,6 @@ 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()
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
@@ -50,18 +50,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 +79,7 @@ async def build_container(settings: Settings) -> Container:
storage=storage,
gitea=gitea,
git=git,
codex=codex,
opencode=opencode,
prompts=prompts,
context=context,
development=development,
@@ -87,10 +88,11 @@ async def build_container(settings: Settings) -> Container:
worker = Worker(
storage=storage,
gitea=gitea,
codex=codex,
opencode=opencode,
dispatcher=dispatcher,
poll_seconds=settings.worker_poll_seconds,
workspaces_dir=settings.workspaces_dir,
)
container = Container(settings, storage, gitea, git, codex, worker)
container = Container(settings, storage, gitea, git, opencode, worker)
log.info("container initialization completed", extra={"operation": "container.build"})
return container
+2
View File
@@ -125,6 +125,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 +137,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
+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;
+29 -5
View File
@@ -3,9 +3,10 @@ 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.opencode import OpenCodeClient
from agentci.adapters.storage import Storage
from agentci.domain.models import Job, JobStatus
from agentci.workflows.common import JobRejected
@@ -20,24 +21,26 @@ class Worker:
*,
storage: Storage,
gitea: GiteaClient,
codex: CodexClient,
opencode: OpenCodeClient,
dispatcher: Dispatcher,
poll_seconds: float,
workspaces_dir: Path,
) -> None:
self.storage = storage
self.gitea = gitea
self.codex = codex
self.opencode = opencode
self.dispatcher = dispatcher
self.poll_seconds = poll_seconds
self.workspaces_dir = workspaces_dir
async def run(self, stop: asyncio.Event) -> None:
log.info("worker started", extra={"operation": "worker.run"})
await self._report_interrupted()
try:
while not stop.is_set():
if not await self.codex.login_ready():
if not await self.opencode.ready():
log.warning(
"worker waiting for Codex authentication",
"worker waiting for OpenCode provider authentication",
extra={"operation": "worker.poll"},
)
await self._wait(stop)
@@ -106,12 +109,33 @@ class Worker:
extra={"operation": "worker.recover", "item_count": len(jobs)},
)
for job in jobs:
await self._abort_job_sessions(job)
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 _abort_job_sessions(self, job: Job) -> None:
sessions: set[tuple[str, Path]] = set()
workflow = None
if job.workflow_id:
workflow = await self.storage.get_workflow(job.workflow_id)
if workflow is not None:
sessions.update(
(session_id, workflow.workspace_path)
for session_id in (
workflow.primary_session_id,
workflow.reviewer_session_id,
)
if session_id
)
elif job.runtime_session_id:
workspace = self.workspaces_dir / f"fix-{job.id}" / "repo"
sessions.add((job.runtime_session_id, workspace))
for session_id, workspace in sessions:
await self.opencode.abort(session_id, workspace)
async def _safe_comment(self, job: Job, body: str) -> None:
try:
await self.gitea.create_comment(
+1 -2
View File
@@ -1,2 +1 @@
"""Codex workflow orchestration."""
"""OpenCode workflow orchestration."""
+1 -2
View File
@@ -22,7 +22,7 @@ class ChangeSet:
) -> str:
await self.deps.storage.update_job(job.id, stage="validating changes")
if not await self.deps.git.has_changes(workspace):
raise JobRejected("Codex completed without producing any file changes.")
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")
@@ -54,4 +54,3 @@ def _commit_title(markdown: str) -> str:
if value:
return value[:72]
return "apply requested changes"
+19 -18
View File
@@ -1,7 +1,12 @@
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.workflows.common import (
Dependencies,
report_for_prompt,
report_json,
required_session,
)
class CodeReviewLoop:
@@ -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
+8 -2
View File
@@ -3,10 +3,10 @@ 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
@@ -18,13 +18,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
+7 -4
View File
@@ -76,16 +76,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 self.deps.storage.update_job(job.id, runtime_session_id=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(
+30 -27
View File
@@ -17,6 +17,7 @@ from agentci.workflows.common import (
agent_comment,
report_for_prompt,
report_json,
required_session,
review_markdown,
)
@@ -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 self.deps.storage.update_job(job.id, runtime_session_id=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,18 +77,22 @@ 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")
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,
@@ -99,6 +107,8 @@ class PlanWorkflow:
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:
@@ -116,12 +126,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,
@@ -152,12 +161,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 +179,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(
@@ -239,9 +248,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
+9 -6
View File
@@ -28,6 +28,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(
@@ -54,12 +56,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,
@@ -125,12 +126,14 @@ class PullRequestWorkflow:
development_environment=self.deps.development.description,
)
await self.deps.storage.update_job(job.id, stage="fixing")
_, result = await self.deps.codex.start(
session_id = await self.deps.opencode.create_session(workspace, "fix")
await self.deps.storage.update_job(job.id, runtime_session_id=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,
)
+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"
+19
View File
@@ -27,9 +27,28 @@ def test_empty_install_scripts_disable_setup() -> None:
assert settings.install_scripts == []
def test_defaults_research_variant_to_high() -> None:
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]
+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()
+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")})
+48
View File
@@ -1,3 +1,4 @@
import sqlite3
from pathlib import Path
import pytest
@@ -101,3 +102,50 @@ async def test_failed_followup_does_not_invalidate_completed_workflow(
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
assert loaded is not None
assert loaded.status is WorkflowStatus.COMPLETED
async def test_opencode_migration_preserves_and_tags_legacy_session_ids(tmp_path: Path) -> None:
legacy_migrations = tmp_path / "legacy-migrations"
legacy_migrations.mkdir()
migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
(legacy_migrations / "001_initial.sql").write_text(
(migrations / "001_initial.sql").read_text()
)
database = tmp_path / "legacy.sqlite3"
legacy = Storage(database, legacy_migrations)
await legacy.initialize()
with sqlite3.connect(database) as connection:
connection.execute(
"""
INSERT INTO workflows (
id, kind, repo_owner, repo_name, issue_number, base_sha,
workspace_path, primary_session_id, reviewer_session_id,
artifact, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"legacy-workflow",
"plan",
"alice",
"repo",
3,
"abc",
str(tmp_path / "repo"),
"legacy-primary",
"legacy-reviewer",
"# Preserved plan",
"completed",
"2026-07-20T00:00:00+00:00",
"2026-07-20T00:00:00+00:00",
),
)
migrated = Storage(database, migrations)
await migrated.initialize()
loaded = await migrated.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
assert loaded is not None
assert loaded.artifact == "# Preserved plan"
assert loaded.primary_session_id == "legacy-primary"
assert loaded.reviewer_session_id == "legacy-reviewer"
assert loaded.runtime == "codex"
+20 -1
View File
@@ -29,9 +29,13 @@ class FakeStorage:
class FakeGitea:
def __init__(self) -> None:
def __init__(self, permitted: bool = True) -> None:
self.permitted = permitted
self.comments: list[str] = []
async def has_write_permission(self, *_args):
return self.permitted
async def create_comment(self, _owner, _repo, _number, body):
self.comments.append(body)
return len(self.comments)
@@ -72,6 +76,21 @@ async def test_authorized_command_is_queued() -> None:
assert "queued" in gitea.comments[0]
async def test_unauthorized_command_is_rejected_and_deduplicated() -> None:
storage = FakeStorage()
gitea = FakeGitea(permitted=False)
container = SimpleNamespace(storage=storage, gitea=gitea)
event = _event_from_payload("delivery", payload("/agent implement"))
assert event is not None
await _handle_command(container, event)
await _handle_command(container, event)
assert storage.jobs == []
assert len(gitea.comments) == 1
assert "write permission" in gitea.comments[0]
async def test_iterate_message_is_preserved_on_queued_job() -> None:
storage = FakeStorage()
container = SimpleNamespace(storage=storage, gitea=FakeGitea())
+80
View File
@@ -0,0 +1,80 @@
from pathlib import Path
from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind
from agentci.worker import Worker
class FakeStorage:
def __init__(self, workflow: Workflow | None) -> None:
self.workflow = workflow
async def get_workflow(self, _workflow_id: str) -> Workflow | None:
return self.workflow
class FakeOpenCode:
def __init__(self) -> None:
self.aborted: set[tuple[str, Path]] = set()
async def abort(self, session_id: str, workspace: Path) -> None:
self.aborted.add((session_id, workspace))
def job(*, workflow_id: str | None, runtime_session_id: str | None = None) -> Job:
return Job(
id="job",
kind=JobKind.FIX,
target_key="org/repo:pr:1",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=1,
requester="alice",
message="",
comment_id=1,
workflow_id=workflow_id,
runtime_session_id=runtime_session_id,
)
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
return Worker(
storage=storage, # type: ignore[arg-type]
gitea=None, # type: ignore[arg-type]
opencode=opencode, # type: ignore[arg-type]
dispatcher=None, # type: ignore[arg-type]
poll_seconds=1,
workspaces_dir=tmp_path,
)
async def test_recovery_aborts_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()
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
job(workflow_id=workflow.id)
)
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
async def test_recovery_aborts_one_shot_fix_session(tmp_path: Path) -> None:
opencode = FakeOpenCode()
await worker(tmp_path, FakeStorage(None), opencode)._abort_job_sessions(
job(workflow_id=None, runtime_session_id="fix-session")
)
assert opencode.aborted == {("fix-session", tmp_path / "fix-job" / "repo")}