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_USERNAME=agentci
AGENTCI_BOT_NAME=Agent CI AGENTCI_BOT_NAME=Agent CI
AGENTCI_BOT_EMAIL=agentci@localhost AGENTCI_BOT_EMAIL=agentci@localhost
AGENTCI_PLAN_MODEL=gpt-5.6-sol OPENCODE_SERVER_USERNAME=opencode
AGENTCI_PLAN_REASONING=medium AGENTCI_OPENCODE_VERSION=^1
AGENTCI_IMPLEMENT_MODEL=gpt-5.6-sol AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
AGENTCI_IMPLEMENT_REASONING=high AGENTCI_PLAN_VARIANT=
AGENTCI_RESEARCH_MODEL=gpt-5.6-luna AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
AGENTCI_RESEARCH_REASONING=high 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. # Optional; Context7 works without a key at lower rate limits.
AGENTCI_CONTEXT7_API_KEY= AGENTCI_CONTEXT7_API_KEY=
AGENTCI_PLAN_REVIEW_ROUNDS=4 AGENTCI_PLAN_REVIEW_ROUNDS=4
@@ -19,5 +23,4 @@ AGENTCI_INSTALL_SCRIPTS=
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900 AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
AGENTCI_PYTHON_VERSION=3.13 AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0 AGENTCI_DOTNET_CHANNEL=10.0
CODEX_VERSION=0.144.6
CODEGRAPH_VERSION=1.3.1 CODEGRAPH_VERSION=1.3.1
+33 -22
View File
@@ -1,12 +1,14 @@
ARG TEA_VERSION=0.14.2 ARG TEA_VERSION=0.14.2
FROM node:24-bookworm-slim AS codex FROM node:24-bookworm-slim AS agent-tools
ARG CODEX_VERSION=0.144.6
ARG CODEGRAPH_VERSION=1.3.1 ARG CODEGRAPH_VERSION=1.3.1
ARG AGENTCI_OPENCODE_VERSION=^1
RUN npm install --global \ RUN npm install --global \
"@openai/codex@${CODEX_VERSION}" \ "opencode-ai@${AGENTCI_OPENCODE_VERSION}" \
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}" "@colbymchenry/codegraph@${CODEGRAPH_VERSION}" \
&& version="$(opencode --version)" \
&& case "$version" in 1.*) ;; *) echo "Expected OpenCode 1.x, got $version" >&2; exit 1;; esac
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
@@ -19,30 +21,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_NO_DEV=1 \ UV_NO_DEV=1 \
CODEGRAPH_TELEMETRY=0 \ CODEGRAPH_TELEMETRY=0 \
CODEX_HOME=/var/lib/codex \
XDG_CONFIG_HOME=/run/agentci \ XDG_CONFIG_HOME=/run/agentci \
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN apt-get update \ RUN apt-get update \
&& apt-get install --yes --no-install-recommends \ && apt-get install --yes --no-install-recommends \
adduser bubblewrap ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \ adduser ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
libicu72 libssl3 libstdc++6 zlib1g \ libicu72 libssl3 libstdc++6 zlib1g \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \ && /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \ && mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \
&& chown agentci:agentci /run/agentci \ /etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \
&& chmod u+s /usr/bin/bwrap && chmod 0555 /etc/opencode/home /etc/opencode/home/.opencode \
/etc/opencode/xdg /etc/opencode/xdg/opencode \
&& chown agentci:agentci /run/agentci
COPY --from=uv /uv /uvx /usr/local/bin/ COPY --from=uv /uv /uvx /usr/local/bin/
COPY --from=tea /bin/tea /usr/local/bin/tea COPY --from=tea /bin/tea /usr/local/libexec/tea
COPY --from=codex /usr/local/bin/node /usr/local/bin/node COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node
COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode
COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \ RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
&& ln -s \
/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \
/usr/local/bin/codex-linux-sandbox \
&& ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
/usr/local/bin/codegraph /usr/local/bin/codegraph
WORKDIR /opt/agentci WORKDIR /opt/agentci
@@ -50,16 +49,28 @@ COPY pyproject.toml uv.lock README.md ./
COPY src ./src COPY src ./src
COPY scripts ./scripts COPY scripts ./scripts
COPY install-scripts /etc/agentci/install-scripts COPY install-scripts /etc/agentci/install-scripts
COPY codex/config.toml /etc/codex/config.toml COPY opencode /etc/opencode
RUN chmod 0755 \ RUN chmod 0755 \
/opt/agentci/scripts/entrypoint.sh \ /opt/agentci/scripts/entrypoint.sh \
/opt/agentci/scripts/gitea-askpass.sh \ /opt/agentci/scripts/gitea-askpass.sh \
/usr/local/bin/tea \ /opt/agentci/scripts/tea.sh \
/usr/local/bin/codex \ /usr/local/libexec/tea \
/usr/local/bin/opencode \
/usr/local/bin/codegraph \ /usr/local/bin/codegraph \
&& ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \
&& uv sync --frozen --no-dev \ && uv sync --frozen --no-dev \
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex && chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/opencode \
&& opencode --version \
&& OPENCODE_CONFIG=/etc/opencode/opencode.json \
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
OPENCODE_PURE=1 \
AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna \
AGENTCI_EXPLORE_VARIANT=low \
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \
AGENTCI_RESEARCH_VARIANT=high \
CONTEXT7_API_KEY= \
opencode debug config >/dev/null
USER agentci USER agentci
EXPOSE 8080 EXPOSE 8080
+93 -81
View File
@@ -1,8 +1,8 @@
# Agent CI # Agent CI
Agent CI is a private Gitea webhook host that turns issue and pull-request Agent CI is a private Gitea webhook host that turns issue and pull-request comments into resumable
comments into resumable Codex planning and implementation workflows. It runs as OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
one persistent Docker Compose service on the same Docker network as Gitea. private OpenCode server on the same Docker network as Gitea.
## Commands ## 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 iterate [message]` | Resume an agent implementation and its reviewer once. |
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. | | PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
Anyone who can comment on an issue or pull request can enqueue commands. Each The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Each
command gets separate queued and started comments. Final plans, PR results, accepted command gets separate queued and started comments. Final plans, PR results, failures, and
failures, and remaining review findings are posted separately. remaining review findings are posted separately.
## Deploy ## Deploy
1. Create a Gitea bot user with repository read/write access and an API token. 1. Create a Gitea bot user with repository read/write access and an API token.
2. Copy `.env.example` to `.env` and set the external network and internal 2. Copy `.env.example` to `.env` and set the external network, Gitea URL, and provider-qualified
Gitea URL. OpenCode models.
3. Create `secrets/gitea_token` containing the bot token and 3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
`secrets/webhook_secret` containing a high-entropy webhook secret. `secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
4. Build and start the service: 4. Build the image:
```sh ```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 ```sh
docker compose exec agentci codex login --device-auth docker compose run --rm opencode opencode auth login
docker compose exec agentci codex login status docker compose run --rm opencode opencode auth list
``` ```
6. In Gitea, create a JSON webhook targeting 6. Start the services:
`http://agentci:8080/webhooks/gitea`. Set the same secret and subscribe to
issue comments, PR timeline comments, and PR review comments.
`/health/live` reports process health. `/health/ready` returns 503 until Codex ```sh
authentication is usable. The worker leaves jobs queued while authentication is docker compose up --no-build -d
missing. ```
## Configuration 7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
Model, reasoning effort, review-pass counts, bot identity, branch prefix, and OpenCode caches provider state. After adding or changing authentication on an already running
turn timeout use `AGENTCI_` environment variables. Defaults are shown in deployment, run the one-off `auth login` command above and then `docker compose restart opencode`.
`.env.example`. Gitea credentials and webhook secrets are intentionally
file-based Compose secrets.
Every planning and implementation session can delegate external research to a `/health/live` reports process health. `/health/ready` returns 503 until the OpenCode server is
read-only `research` subagent. It defaults to `gpt-5.6-luna` with high reasoning healthy and every configured model exists, supports tool calls, accepts its configured variant, and
and has public network access, live web search, Context7 documentation lookup, has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
and `gh_grep` public GitHub code search. Configure its model and effort with
`AGENTCI_RESEARCH_MODEL` and `AGENTCI_RESEARCH_REASONING`. Context7 works
without authentication at lower rate limits; set the optional
`AGENTCI_CONTEXT7_API_KEY` for authenticated usage. The key is passed only to
Codex's Context7 MCP transport and is excluded from agent shell environments.
Planning, implementation, and all review sessions also receive the local ## OpenCode
CodeGraph MCP server for repository structure, symbol relationships, and change
impact. Agent CI initializes or refreshes the index before every Codex turn and
locally excludes `.codegraph/` from Git. The research subagent intentionally
does not receive CodeGraph.
### Development environments 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 `AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults
environment installers. The supplied `python` and `dotnet` scripts install only to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker
their runtimes; they are ordinary scripts that can be replaced or removed. may reuse the cached installation layer until the configured version or build inputs change.
Implementation agents remain responsible for restoring project dependencies Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built.
and selecting build/test commands. Configure the supplied scripts with
`AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`: The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
Repository-local OpenCode config and external plugins are disabled so a clone cannot replace the
service policy. OpenCode's default plugins remain enabled for provider authentication. Its scanned
home and global configuration paths are root-owned and read-only, and external skill discovery is
disabled, so an agent cannot persist instructions for later repositories. CodeGraph, Context7, and
`gh_grep` are configured as MCP servers. Set `AGENTCI_CONTEXT7_API_KEY` to raise Context7 rate
limits.
The `research` subagent can only use Exa web search, Context7, and the `gh_grep` public-code search
MCP. All filesystem, shell, editing, task, and other tools are denied for that agent. Exa is enabled
with `OPENCODE_ENABLE_EXA=1`. Agent CI initializes or refreshes CodeGraph before every parent turn
and locally excludes `.codegraph/` from Git.
### Security boundary
OpenCode does not provide an OS sandbox. Its default permission system controls agent tools and
approval requests, while Docker limits what the non-root process can reach. Any shell command that
OpenCode permits still has the Unix-level access of that container user, so environment filtering
is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command.
Docker remains the OS boundary. The services run as non-root without added capabilities,
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only
host bind mount is the read-only installer directory; there are no
writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password
protected, and is reachable by Agent CI over an internal Compose network.
## Development environments
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development environment installers.
The supplied `python` and `dotnet` scripts install only their runtimes; they can be replaced or
removed. Configure them with `AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
```env ```env
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
@@ -84,44 +108,30 @@ AGENTCI_PYTHON_VERSION=3.13
AGENTCI_DOTNET_CHANNEL=10.0 AGENTCI_DOTNET_CHANNEL=10.0
``` ```
Every name resolves to an executable file in `install-scripts/`, mounted Every name resolves to a file in `install-scripts/`, mounted read-only at
read-only at `/etc/agentci/install-scripts`. Names cannot contain paths and `/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers
duplicates are rejected. See `install-scripts/README.md` for the script contract. run after each implementation clone or branch sync and fail the job on an unknown script, timeout,
or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain
trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or
modify them through shell commands permitted by its active agent policy. See
`install-scripts/README.md` for the script contract.
Installers run in order after each implementation clone or branch sync and fail Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
the job on an unknown script, timeout, or non-zero exit. They receive no AgentCI keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
or Gitea secret values in their environment, but remain trusted operator code Git commands itself.
running as the service user. Tools persist under `/var/lib/agentci/dev-tools`;
its `bin` directory is added to implementation agents' `PATH` with read-only
sandbox access. The agents can use those tools for builds and validation, but
Agent CI does not impose host-side build commands.
Planning/review commands can only read their workflow clone and have no shell
network access. Implementation/fix commands can edit the clone but cannot
modify `.git`; they can reach public internet destinations while private and
loopback destinations remain blocked. Codex's interactive Git trust check is
skipped because every turn runs non-interactively against a service-owned clone;
the configured filesystem and network permissions still apply. Git credentials
exist only in the service-owned clone/push subprocess and are not inherited by
Codex turns. The image and Compose capability/security settings let the non-root
service create Codex's nested `bwrap` sandbox. They follow Codex's secure
devcontainer pattern instead of making the service container privileged. Do not
remove Codex's configured filesystem and network restrictions. In particular,
`systempaths=unconfined` removes Docker's outer masked `/proc` subpaths so the
nested user/PID namespace can mount its own procfs. Runtimes such as CoreCLR
require `/proc/self/maps`; Codex still controls visibility through the fresh
procfs and its filesystem policy.
## State and recovery ## State and recovery
The `agentci_data` volume contains SQLite, persistent workflow clones, and The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes.
installed development runtimes. The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable
`codex_home` contains login state and resumable Codex sessions. Both are kept sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
indefinitely and should be backed up together. `opencode_home`. Back up both persistent volumes together.
Queued jobs survive restart. An in-progress job is marked failed after restart The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for
instead of being replayed, because replaying a partially completed model turn rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a
could duplicate changes. Git pushes are never forced. 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 ## Development
@@ -132,7 +142,9 @@ uv sync
uv run ruff check . uv run ruff check .
uv run pyright uv run pyright
uv run pytest uv run pytest
docker compose config
docker compose build
``` ```
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
schemas live outside Python so orchestration modules remain small and readable. 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: services:
agentci: agentci:
image: agentci:local
build: build:
context: . context: .
args: args:
CODEX_VERSION: ${CODEX_VERSION:-0.144.6} AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1} CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
TEA_VERSION: ${TEA_VERSION:-0.14.2} TEA_VERSION: ${TEA_VERSION:-0.14.2}
restart: unless-stopped restart: unless-stopped
# Codex applies its own bwrap sandbox inside this otherwise unprivileged container. depends_on:
cap_add: opencode:
- SYS_ADMIN condition: service_healthy
- SYS_CHROOT
- SETUID
- SETGID
- SYS_PTRACE
- NET_ADMIN
- NET_RAW
security_opt:
- seccomp=unconfined
- apparmor=unconfined
# Docker's masked /proc paths prevent Codex's user namespace from mounting fresh procfs.
- systempaths=unconfined
environment: environment:
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000} AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci} AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci}
AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI} AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI}
AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost} AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost}
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-gpt-5.6-sol} AGENTCI_OPENCODE_URL: http://opencode:4096
AGENTCI_PLAN_REASONING: ${AGENTCI_PLAN_REASONING:-medium} AGENTCI_OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-gpt-5.6-sol} AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-openai/gpt-5.6-sol}
AGENTCI_IMPLEMENT_REASONING: ${AGENTCI_IMPLEMENT_REASONING:-high} AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-}
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-gpt-5.6-luna} AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol}
AGENTCI_RESEARCH_REASONING: ${AGENTCI_RESEARCH_REASONING:-high} AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-}
AGENTCI_CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-} AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna}
AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}
AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4} AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4}
AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3} AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600} AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600}
@@ -43,26 +36,81 @@ services:
secrets: secrets:
- gitea_token - gitea_token
- webhook_secret - webhook_secret
- opencode_server_password
volumes: volumes:
- agentci_data:/var/lib/agentci - agentci_data:/var/lib/agentci
- codex_home:/var/lib/codex
- ./install-scripts:/etc/agentci/install-scripts:ro - ./install-scripts:/etc/agentci/install-scripts:ro
tmpfs:
- /run/agentci:mode=1777
expose: expose:
- "8080" - "8080"
networks: networks:
- gitea - gitea
- agentci_control
opencode:
image: agentci: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: secrets:
gitea_token: gitea_token:
file: ./secrets/gitea_token file: ./secrets/gitea_token
webhook_secret: webhook_secret:
file: ./secrets/webhook_secret file: ./secrets/webhook_secret
opencode_server_password:
file: ./secrets/opencode_server_password
volumes: volumes:
agentci_data: agentci_data:
codex_home: opencode_home:
networks: networks:
gitea: gitea:
external: true external: true
name: ${GITEA_NETWORK:-gitea} name: ${GITEA_NETWORK:-gitea}
agentci_control:
internal: true
+3 -4
View File
@@ -18,10 +18,9 @@ files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/b
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
not persist into implementation turns. not persist into implementation turns.
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state
.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only
normal user home and receive read-only access to installed tools, so runtime caches cannot live so it cannot be used to persist global agent configuration.
beside the SDK.
The configured scripts run in list order after the repository is cloned (or an existing agent PR The configured scripts run in list order after the repository is cloned (or an existing agent PR
branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and
+4 -3
View File
@@ -6,6 +6,7 @@ set -eu
install_dir="$DEV_TOOLS_DIR/dotnet" install_dir="$DEV_TOOLS_DIR/dotnet"
bin_dir="$DEV_TOOLS_DIR/bin" bin_dir="$DEV_TOOLS_DIR/bin"
runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet"
installer=$(mktemp) installer=$(mktemp)
trap 'rm -f "$installer"' EXIT trap 'rm -f "$installer"' EXIT
@@ -25,9 +26,9 @@ PYTHON
printf '%s\n' \ printf '%s\n' \
'#!/bin/sh' \ '#!/bin/sh' \
"export DOTNET_ROOT='$install_dir'" \ "export DOTNET_ROOT='$install_dir'" \
'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \ "export DOTNET_CLI_HOME='$runtime_dir/home'" \
'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \ "export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \
'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \ "export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \
'export HOME="$DOTNET_CLI_HOME"' \ 'export HOME="$DOTNET_CLI_HOME"' \
'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \ 'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \
'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \ 'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \
+6
View File
@@ -0,0 +1,6 @@
# Agent CI runtime
A `research` subagent is available in every workflow. Delegate focused external research when
current documentation, web evidence, or public code examples would materially improve the result.
Keep repository analysis and edits in the parent agent. Use CodeGraph for repository architecture,
symbol relationships, and change-impact analysis when useful.
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"share": "disabled",
"subagent_depth": 1,
"instructions": ["/etc/opencode/AGENTS.md"],
"agent": {
"explore": {
"model": "{env:AGENTCI_EXPLORE_MODEL}",
"variant": "{env:AGENTCI_EXPLORE_VARIANT}"
},
"research": {
"description": "Research current documentation, web evidence, and public code examples.",
"mode": "subagent",
"model": "{env:AGENTCI_RESEARCH_MODEL}",
"variant": "{env:AGENTCI_RESEARCH_VARIANT}",
"permission": {
"*": "deny",
"websearch": "allow",
"context7_*": "allow",
"gh_grep_*": "allow"
},
"prompt": "Research external, current, or unfamiliar technical facts for the parent agent. Use Context7 for library documentation, gh_grep for public-code examples, and web search for primary sources or broader verification. Prefer authoritative sources, report links, distinguish facts from inference, and return a concise evidence-focused summary. Do not include secrets or proprietary source in external queries."
}
},
"mcp": {
"codegraph": {
"type": "local",
"command": ["codegraph", "serve", "--mcp"],
"environment": {
"CODEGRAPH_TELEMETRY": "0"
},
"enabled": true,
"timeout": 60000
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
},
"enabled": true
},
"gh_grep": {
"type": "remote",
"url": "https://mcp.grep.app",
"enabled": true
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agentci" name = "agentci"
version = "0.1.0" version = "0.1.0"
description = "A Gitea webhook host that orchestrates resumable Codex workflows" description = "A Gitea webhook host that orchestrates resumable OpenCode workflows"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
+9 -2
View File
@@ -1,12 +1,18 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
python -c ' if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then
export OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE")
fi
if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then
python -c '
import json import json
import os import os
from pathlib import Path from pathlib import Path
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea" config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea"
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.yml" config_path = config_dir / "config.yml"
token_path = Path( token_path = Path(
@@ -22,5 +28,6 @@ login = {
config_path.write_text(json.dumps({"logins": [login], "preferences": {}})) config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
config_path.chmod(0o600) config_path.chmod(0o600)
' '
fi
exec "$@" exec "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}"
exec /usr/local/libexec/tea "$@"
+1 -2
View File
@@ -1,4 +1,3 @@
"""Gitea-triggered Codex workflow host.""" """Gitea-triggered OpenCode workflow host."""
__version__ = "0.1.0" __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: async def close(self) -> None:
await self.client.aclose() 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: async def repository(self, owner: str, repo: str) -> RepositoryInfo:
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json() data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
return RepositoryInfo( return RepositoryInfo(
+8 -3
View File
@@ -39,8 +39,8 @@ class JobStore(Database):
INSERT INTO jobs ( INSERT INTO jobs (
id, kind, target_key, repo_owner, repo_name, issue_number, id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, workflow_id, pr_number, requester, message, comment_id, workflow_id,
status, stage, created_at status, stage, runtime_session_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
job.id, job.id,
@@ -56,6 +56,7 @@ class JobStore(Database):
job.workflow_id, job.workflow_id,
job.status, job.status,
job.stage, job.stage,
job.runtime_session_id,
now(), now(),
), ),
) )
@@ -91,6 +92,7 @@ class JobStore(Database):
stage: str | None = None, stage: str | None = None,
error: str | None = None, error: str | None = None,
workflow_id: str | None = None, workflow_id: str | None = None,
runtime_session_id: str | None = None,
) -> None: ) -> None:
updates: dict[str, object] = {} updates: dict[str, object] = {}
if status is not None: if status is not None:
@@ -103,6 +105,8 @@ class JobStore(Database):
updates["error"] = error updates["error"] = error
if workflow_id is not None: if workflow_id is not None:
updates["workflow_id"] = workflow_id 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) await self._update("jobs", job_id, updates)
log.info( log.info(
"job state updated", "job state updated",
@@ -161,7 +165,7 @@ class JobStore(Database):
( (
JobStatus.FAILED, JobStatus.FAILED,
"interrupted", "interrupted",
"Service restarted during an active Codex turn", "Service restarted during an active OpenCode turn",
now(), now(),
JobStatus.RUNNING, JobStatus.RUNNING,
), ),
@@ -192,4 +196,5 @@ def job_from_row(
status=status or JobStatus(row["status"]), status=status or JobStatus(row["status"]),
stage=stage or row["stage"], stage=stage or row["stage"],
accepted_comment_id=row["accepted_comment_id"], 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): 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: async def create_workflow(self, workflow: Workflow) -> None:
timestamp = now() timestamp = now()
await self._run( await self._run(
@@ -16,9 +25,9 @@ class WorkflowStore(Database):
INSERT INTO workflows ( INSERT INTO workflows (
id, kind, repo_owner, repo_name, issue_number, pr_number, id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, 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 created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
workflow.id, workflow.id,
@@ -35,6 +44,7 @@ class WorkflowStore(Database):
workflow.artifact, workflow.artifact,
workflow.review_json, workflow.review_json,
workflow.status, workflow.status,
workflow.runtime,
timestamp, timestamp,
timestamp, timestamp,
), ),
@@ -135,6 +145,7 @@ def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
issue_number=row["issue_number"], issue_number=row["issue_number"],
pr_number=row["pr_number"], pr_number=row["pr_number"],
base_sha=row["base_sha"], base_sha=row["base_sha"],
runtime=row["runtime"],
branch=row["branch"], branch=row["branch"],
workspace_path=Path(row["workspace_path"]), workspace_path=Path(row["workspace_path"]),
primary_session_id=row["primary_session_id"], 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") @router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]: 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 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"} 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"): if not event.body.strip().startswith("/agent"):
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
log.info("agent command received", extra=extra) 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: try:
command = parse_command(event.body) command = parse_command(event.body)
except CommandError as exc: except CommandError as exc:
+4 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -37,8 +37,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
finally: finally:
log.info("service shutdown started", extra={"operation": "service.shutdown"}) log.info("service shutdown started", extra={"operation": "service.shutdown"})
stop.set() stop.set()
worker_task.cancel()
try: try:
await worker_task with suppress(asyncio.CancelledError):
await worker_task
finally: finally:
await container.close() await container.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"}) 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 pathlib import Path
from typing import Annotated from typing import Annotated
from pydantic import Field, SecretStr, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
@@ -21,38 +21,48 @@ class Settings(BaseSettings):
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8080 port: int = 8080
data_dir: Path = Path("/var/lib/agentci") data_dir: Path = Path("/var/lib/agentci")
codex_home: Path = Path("/var/lib/codex")
gitea_url: str = "http://gitea:3000" gitea_url: str = "http://gitea:3000"
gitea_token_file: Path = Path("/run/secrets/gitea_token") gitea_token_file: Path = Path("/run/secrets/gitea_token")
webhook_secret_file: Path = Path("/run/secrets/webhook_secret") webhook_secret_file: Path = Path("/run/secrets/webhook_secret")
opencode_url: str = "http://opencode:4096"
opencode_server_username: str = "opencode"
opencode_server_password_file: Path = Path("/run/secrets/opencode_server_password")
bot_username: str = "agentci" bot_username: str = "agentci"
bot_name: str = "Agent CI" bot_name: str = "Agent CI"
bot_email: str = "agentci@localhost" bot_email: str = "agentci@localhost"
branch_prefix: str = "agent" branch_prefix: str = "agent"
askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh") askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh")
plan_model: str = "gpt-5.6-sol" plan_model: str = "openai/gpt-5.6-sol"
plan_reasoning: str = "medium" plan_variant: str | None = None
implement_model: str = "gpt-5.6-sol" implement_model: str = "openai/gpt-5.6-sol"
implement_reasoning: str = "high" implement_variant: str | None = None
research_model: str = "gpt-5.6-luna" explore_model: str = "openai/gpt-5.6-luna"
research_reasoning: str = "high" explore_variant: str = "low"
context7_api_key: SecretStr | None = None research_model: str = "openai/gpt-5.6-luna"
research_variant: str = "high"
plan_review_rounds: int = Field(default=4, ge=1, le=20) plan_review_rounds: int = Field(default=4, ge=1, le=20)
implement_review_rounds: int = Field(default=3, ge=1, le=20) implement_review_rounds: int = Field(default=3, ge=1, le=20)
turn_timeout_seconds: int = Field(default=3600, ge=60) turn_timeout_seconds: int = Field(default=3600, ge=60)
install_script_timeout_seconds: int = Field(default=900, ge=1) install_script_timeout_seconds: int = Field(default=900, ge=1)
worker_poll_seconds: float = Field(default=1.0, ge=0.1) worker_poll_seconds: float = Field(default=1.0, ge=0.1)
public_agent_network: bool = True
install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list) install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list)
install_scripts_dir: Path = Path("/etc/agentci/install-scripts") install_scripts_dir: Path = Path("/etc/agentci/install-scripts")
python_version: str = "3.13" python_version: str = "3.13"
dotnet_channel: str = "10.0" dotnet_channel: str = "10.0"
@field_validator("gitea_url") @field_validator("gitea_url", "opencode_url")
@classmethod @classmethod
def strip_url(cls, value: str) -> str: def strip_url(cls, value: str) -> str:
return value.rstrip("/") return value.rstrip("/")
@field_validator("plan_model", "implement_model", "explore_model", "research_model")
@classmethod
def validate_opencode_model(cls, value: str) -> str:
provider, separator, model = value.partition("/")
if not separator or not provider or not model:
raise ValueError("OpenCode models must use provider/model format")
return value
@field_validator("install_scripts", mode="before") @field_validator("install_scripts", mode="before")
@classmethod @classmethod
def parse_install_scripts(cls, value: object) -> list[str]: def parse_install_scripts(cls, value: object) -> list[str]:
@@ -77,6 +87,12 @@ class Settings(BaseSettings):
def webhook_secret(self) -> bytes: def webhook_secret(self) -> bytes:
return self._read_secret(self.webhook_secret_file, "webhook secret").encode() return self._read_secret(self.webhook_secret_file, "webhook secret").encode()
@cached_property
def opencode_server_password(self) -> str:
return self._read_secret(
self.opencode_server_password_file, "OpenCode server password"
)
@property @property
def database_path(self) -> Path: def database_path(self) -> Path:
return self.data_dir / "agentci.sqlite3" return self.data_dir / "agentci.sqlite3"
+18 -16
View File
@@ -4,10 +4,10 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from agentci.adapters.codex import CodexClient
from agentci.adapters.development import DevelopmentEnvironment from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage from agentci.adapters.storage import Storage
from agentci.config import Settings from agentci.config import Settings
from agentci.prompts import PromptLibrary from agentci.prompts import PromptLibrary
@@ -24,11 +24,12 @@ class Container:
storage: Storage storage: Storage
gitea: GiteaClient gitea: GiteaClient
git: GitClient git: GitClient
codex: CodexClient opencode: OpenCodeClient
worker: Worker worker: Worker
async def close(self) -> None: async def close(self) -> None:
log.info("container shutdown started", extra={"operation": "container.close"}) log.info("container shutdown started", extra={"operation": "container.close"})
await self.opencode.close()
await self.gitea.close() await self.gitea.close()
log.info("container shutdown completed", extra={"operation": "container.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 package_dir = Path(__file__).parent
settings.data_dir.mkdir(parents=True, exist_ok=True) settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.workspaces_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") storage = Storage(settings.database_path, package_dir / "migrations")
await storage.initialize() await storage.initialize()
gitea = GiteaClient(settings.gitea_url, settings.gitea_token) 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_name=settings.bot_name,
commit_email=settings.bot_email, commit_email=settings.bot_email,
) )
codex = CodexClient( opencode = OpenCodeClient(
codex_home=settings.codex_home, base_url=settings.opencode_url,
username=settings.opencode_server_username,
password=settings.opencode_server_password,
schemas_dir=package_dir / "prompts" / "schemas", schemas_dir=package_dir / "prompts" / "schemas",
timeout_seconds=settings.turn_timeout_seconds, health_directory=settings.workspaces_dir,
research_model=settings.research_model, required_models=(
research_reasoning=settings.research_reasoning, (settings.plan_model, settings.plan_variant),
context7_api_key=( (settings.implement_model, settings.implement_variant),
settings.context7_api_key.get_secret_value() (settings.explore_model, settings.explore_variant),
if settings.context7_api_key is not None (settings.research_model, settings.research_variant),
else None
), ),
tools_bin=settings.dev_tools_dir / "bin", timeout_seconds=settings.turn_timeout_seconds,
) )
prompts = PromptLibrary() prompts = PromptLibrary()
context = ContextBuilder(gitea, storage) context = ContextBuilder(gitea, storage)
@@ -78,7 +79,7 @@ async def build_container(settings: Settings) -> Container:
storage=storage, storage=storage,
gitea=gitea, gitea=gitea,
git=git, git=git,
codex=codex, opencode=opencode,
prompts=prompts, prompts=prompts,
context=context, context=context,
development=development, development=development,
@@ -87,10 +88,11 @@ async def build_container(settings: Settings) -> Container:
worker = Worker( worker = Worker(
storage=storage, storage=storage,
gitea=gitea, gitea=gitea,
codex=codex, opencode=opencode,
dispatcher=dispatcher, dispatcher=dispatcher,
poll_seconds=settings.worker_poll_seconds, 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"}) log.info("container initialization completed", extra={"operation": "container.build"})
return container return container
+2
View File
@@ -125,6 +125,7 @@ class Job:
status: JobStatus = JobStatus.QUEUED status: JobStatus = JobStatus.QUEUED
stage: str = "queued" stage: str = "queued"
accepted_comment_id: int | None = None accepted_comment_id: int | None = None
runtime_session_id: str | None = None
@dataclass @dataclass
@@ -136,6 +137,7 @@ class Workflow:
issue_number: int issue_number: int
workspace_path: Path workspace_path: Path
base_sha: str base_sha: str
runtime: str = "opencode"
branch: str | None = None branch: str | None = None
pr_number: int | None = None pr_number: int | None = None
primary_session_id: str | None = None primary_session_id: str | None = None
+2
View File
@@ -49,3 +49,5 @@ def configure_logging() -> None:
root.handlers.clear() root.handlers.clear()
root.addHandler(handler) root.addHandler(handler)
root.setLevel(logging.INFO) root.setLevel(logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
@@ -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 asyncio
import logging import logging
from contextlib import suppress from contextlib import suppress
from pathlib import Path
from agentci.adapters.codex import CodexClient
from agentci.adapters.gitea import GiteaClient from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage from agentci.adapters.storage import Storage
from agentci.domain.models import Job, JobStatus from agentci.domain.models import Job, JobStatus
from agentci.workflows.common import JobRejected from agentci.workflows.common import JobRejected
@@ -20,24 +21,26 @@ class Worker:
*, *,
storage: Storage, storage: Storage,
gitea: GiteaClient, gitea: GiteaClient,
codex: CodexClient, opencode: OpenCodeClient,
dispatcher: Dispatcher, dispatcher: Dispatcher,
poll_seconds: float, poll_seconds: float,
workspaces_dir: Path,
) -> None: ) -> None:
self.storage = storage self.storage = storage
self.gitea = gitea self.gitea = gitea
self.codex = codex self.opencode = opencode
self.dispatcher = dispatcher self.dispatcher = dispatcher
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
self.workspaces_dir = workspaces_dir
async def run(self, stop: asyncio.Event) -> None: async def run(self, stop: asyncio.Event) -> None:
log.info("worker started", extra={"operation": "worker.run"}) log.info("worker started", extra={"operation": "worker.run"})
await self._report_interrupted() await self._report_interrupted()
try: try:
while not stop.is_set(): while not stop.is_set():
if not await self.codex.login_ready(): if not await self.opencode.ready():
log.warning( log.warning(
"worker waiting for Codex authentication", "worker waiting for OpenCode provider authentication",
extra={"operation": "worker.poll"}, extra={"operation": "worker.poll"},
) )
await self._wait(stop) await self._wait(stop)
@@ -106,12 +109,33 @@ class Worker:
extra={"operation": "worker.recover", "item_count": len(jobs)}, extra={"operation": "worker.recover", "item_count": len(jobs)},
) )
for job in jobs: for job in jobs:
await self._abort_job_sessions(job)
await self._safe_fail_workflow(job) await self._safe_fail_workflow(job)
await self._safe_comment( await self._safe_comment(
job, job,
f"Agent job `{job.id}` failed because the service restarted during execution.", 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: async def _safe_comment(self, job: Job, body: str) -> None:
try: try:
await self.gitea.create_comment( 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: ) -> str:
await self.deps.storage.update_job(job.id, stage="validating changes") await self.deps.storage.update_job(job.id, stage="validating changes")
if not await self.deps.git.has_changes(workspace): 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) await self.deps.git.diff_check(workspace)
title = _commit_title(result.summary_markdown) title = _commit_title(result.summary_markdown)
await self.deps.storage.update_job(job.id, stage="committing changes") await self.deps.storage.update_job(job.id, stage="committing changes")
@@ -54,4 +54,3 @@ def _commit_title(markdown: str) -> str:
if value: if value:
return value[:72] return value[:72]
return "apply requested changes" return "apply requested changes"
+19 -18
View File
@@ -1,7 +1,12 @@
from __future__ import annotations from __future__ import annotations
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow 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: class CodeReviewLoop:
@@ -44,12 +49,11 @@ class CodeReviewLoop:
review=report_for_prompt(workflow.review_json), review=report_for_prompt(workflow.review_json),
development_environment=self.deps.development.description, development_environment=self.deps.development.description,
) )
result = await self.deps.codex.resume( result = await self.deps.opencode.resume(
session_id=_required(workflow.primary_session_id), session_id=required_session(workflow.primary_session_id),
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=self.deps.settings.implement_variant,
permission="agentci-write",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="agent_result.json", schema_name="agent_result.json",
result_type=AgentResult, result_type=AgentResult,
@@ -71,30 +75,27 @@ class CodeReviewLoop:
pull_context=pull_context, pull_context=pull_context,
) )
if workflow.reviewer_session_id: if workflow.reviewer_session_id:
return await self.deps.codex.resume( return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id, session_id=workflow.reviewer_session_id,
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=self.deps.settings.implement_variant,
permission="agentci-review",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="review.json", schema_name="review.json",
result_type=ReviewReport, 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, workspace=workflow.workspace_path,
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=self.deps.settings.implement_variant,
permission="agentci-review",
schema_name="review.json", schema_name="review.json",
result_type=ReviewReport, result_type=ReviewReport,
) )
workflow.reviewer_session_id = session_id
return report 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 import json
from dataclasses import dataclass from dataclasses import dataclass
from agentci.adapters.codex import CodexClient
from agentci.adapters.development import DevelopmentEnvironment from agentci.adapters.development import DevelopmentEnvironment
from agentci.adapters.git import GitClient from agentci.adapters.git import GitClient
from agentci.adapters.gitea import GiteaClient from agentci.adapters.gitea import GiteaClient
from agentci.adapters.opencode import OpenCodeClient
from agentci.adapters.storage import Storage from agentci.adapters.storage import Storage
from agentci.config import Settings from agentci.config import Settings
from agentci.domain.models import ReviewReport from agentci.domain.models import ReviewReport
@@ -18,13 +18,19 @@ class JobRejected(RuntimeError):
"""A safe, expected workflow rejection to publish to the requester.""" """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) @dataclass(frozen=True)
class Dependencies: class Dependencies:
settings: Settings settings: Settings
storage: Storage storage: Storage
gitea: GiteaClient gitea: GiteaClient
git: GitClient git: GitClient
codex: CodexClient opencode: OpenCodeClient
prompts: PromptLibrary prompts: PromptLibrary
context: ContextBuilder context: ContextBuilder
development: DevelopmentEnvironment development: DevelopmentEnvironment
+7 -4
View File
@@ -76,16 +76,19 @@ class ImplementWorkflow:
request=job.message or "(no additional request)", request=job.message or "(no additional request)",
development_environment=self.deps.development.description, 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, workspace=workspace,
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=self.deps.settings.implement_variant,
permission="agentci-write",
schema_name="agent_result.json", schema_name="agent_result.json",
result_type=AgentResult, result_type=AgentResult,
) )
workflow.primary_session_id = session_id
workflow.artifact = result.model_dump_json() workflow.artifact = result.model_dump_json()
await self.deps.storage.update_workflow(workflow) await self.deps.storage.update_workflow(workflow)
result, report = await self.review.run( result, report = await self.review.run(
+30 -27
View File
@@ -17,6 +17,7 @@ from agentci.workflows.common import (
agent_comment, agent_comment,
report_for_prompt, report_for_prompt,
report_json, report_json,
required_session,
review_markdown, review_markdown,
) )
@@ -56,16 +57,19 @@ class PlanWorkflow:
context=context, context=context,
request=job.message or "(no additional request)", request=job.message or "(no additional request)",
) )
session_id, artifact = await self.deps.codex.start( session_id = await 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, workspace=workspace,
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=self.deps.settings.plan_variant,
permission="agentci-read",
schema_name="plan.json", schema_name="plan.json",
result_type=PlanArtifact, result_type=PlanArtifact,
) )
workflow.primary_session_id = session_id
workflow.artifact = artifact.plan_markdown workflow.artifact = artifact.plan_markdown
await self.deps.storage.update_workflow(workflow) await self.deps.storage.update_workflow(workflow)
report = await self._review_loop(job, workflow, context, artifact) report = await self._review_loop(job, workflow, context, artifact)
@@ -73,18 +77,22 @@ class PlanWorkflow:
async def discuss(self, job: Job) -> None: async def discuss(self, job: Job) -> None:
workflow = await self._latest_plan(job) 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: if not workflow.primary_session_id or not workflow.artifact:
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.") raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing") await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing")
prompt = self.deps.prompts.render( prompt = self.deps.prompts.render(
"discuss", artifact=workflow.artifact, message=job.message "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, session_id=workflow.primary_session_id,
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=self.deps.settings.plan_variant,
permission="agentci-read",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="discussion.json", schema_name="discussion.json",
result_type=DiscussionReply, result_type=DiscussionReply,
@@ -99,6 +107,8 @@ class PlanWorkflow:
async def iterate(self, job: Job) -> None: async def iterate(self, job: Job) -> None:
await self._reject_if_active_or_merged_pr(job) await self._reject_if_active_or_merged_pr(job)
workflow = await self._latest_plan(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: if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.") raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
if not workflow.artifact: if not workflow.artifact:
@@ -116,12 +126,11 @@ class PlanWorkflow:
review=report_for_prompt(workflow.review_json), review=report_for_prompt(workflow.review_json),
message=job.message or "(refine using the latest discussion and prior review)", message=job.message or "(refine using the latest discussion and prior review)",
) )
artifact = await self.deps.codex.resume( artifact = await self.deps.opencode.resume(
session_id=workflow.primary_session_id, session_id=workflow.primary_session_id,
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=self.deps.settings.plan_variant,
permission="agentci-read",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="plan.json", schema_name="plan.json",
result_type=PlanArtifact, result_type=PlanArtifact,
@@ -152,12 +161,11 @@ class PlanWorkflow:
artifact=artifact.plan_markdown, artifact=artifact.plan_markdown,
review=report_for_prompt(workflow.review_json), review=report_for_prompt(workflow.review_json),
) )
artifact = await self.deps.codex.resume( artifact = await self.deps.opencode.resume(
session_id=_required(workflow.primary_session_id), session_id=required_session(workflow.primary_session_id),
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=self.deps.settings.plan_variant,
permission="agentci-read",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="plan.json", schema_name="plan.json",
result_type=PlanArtifact, result_type=PlanArtifact,
@@ -171,26 +179,27 @@ class PlanWorkflow:
"plan_review", context=context, artifact=artifact.plan_markdown "plan_review", context=context, artifact=artifact.plan_markdown
) )
if workflow.reviewer_session_id: if workflow.reviewer_session_id:
return await self.deps.codex.resume( return await self.deps.opencode.resume(
session_id=workflow.reviewer_session_id, session_id=workflow.reviewer_session_id,
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=self.deps.settings.plan_variant,
permission="agentci-review",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="review.json", schema_name="review.json",
result_type=ReviewReport, 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, workspace=workflow.workspace_path,
prompt=prompt, prompt=prompt,
model=self.deps.settings.plan_model, model=self.deps.settings.plan_model,
reasoning=self.deps.settings.plan_reasoning, variant=self.deps.settings.plan_variant,
permission="agentci-review",
schema_name="review.json", schema_name="review.json",
result_type=ReviewReport, result_type=ReviewReport,
) )
workflow.reviewer_session_id = session_id
return report return report
async def _finish( async def _finish(
@@ -239,9 +248,3 @@ class PlanWorkflow:
f"Issue plan iteration is disabled because agent PR #{pull.number} " f"Issue plan iteration is disabled because agent PR #{pull.number} "
"is open or merged. Iterate an open implementation on its PR." "is open or merged. Iterate an open implementation on its PR."
) )
def _required(value: str | None) -> str:
if value is None: # Defensive: workflows with missing sessions are rejected earlier.
raise RuntimeError("Expected a persisted Codex session ID")
return value
+9 -6
View File
@@ -28,6 +28,8 @@ class PullRequestWorkflow:
raise JobRejected( raise JobRejected(
"This is not an open agent-created implementation PR. Use `/agent fix`." "This is not an open agent-created implementation PR. Use `/agent fix`."
) )
if workflow.runtime != "opencode":
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
if not workflow.primary_session_id or not workflow.reviewer_session_id: if not workflow.primary_session_id or not workflow.reviewer_session_id:
raise JobRejected("The implementation sessions cannot be resumed.") raise JobRejected("The implementation sessions cannot be resumed.")
pull, context = await self.deps.context.pull_request_context( pull, context = await self.deps.context.pull_request_context(
@@ -54,12 +56,11 @@ class PullRequestWorkflow:
message=job.message or "(perform one additional reviewed refinement)", message=job.message or "(perform one additional reviewed refinement)",
development_environment=self.deps.development.description, development_environment=self.deps.development.description,
) )
result = await self.deps.codex.resume( result = await self.deps.opencode.resume(
session_id=workflow.primary_session_id, session_id=workflow.primary_session_id,
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=self.deps.settings.implement_variant,
permission="agentci-write",
workspace=workflow.workspace_path, workspace=workflow.workspace_path,
schema_name="agent_result.json", schema_name="agent_result.json",
result_type=AgentResult, result_type=AgentResult,
@@ -125,12 +126,14 @@ class PullRequestWorkflow:
development_environment=self.deps.development.description, development_environment=self.deps.development.description,
) )
await self.deps.storage.update_job(job.id, stage="fixing") 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, workspace=workspace,
prompt=prompt, prompt=prompt,
model=self.deps.settings.implement_model, model=self.deps.settings.implement_model,
reasoning=self.deps.settings.implement_reasoning, variant=self.deps.settings.implement_variant,
permission="agentci-write",
schema_name="agent_result.json", schema_name="agent_result.json",
result_type=AgentResult, 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: def __init__(self, reports: list[ReviewReport]) -> None:
self.reports = iter(reports) self.reports = iter(reports)
self.reviews = 0 self.reviews = 0
self.revisions = 0 self.revisions = 0
async def start(self, **_kwargs): async def create_session(self, *_args):
self.reviews += 1 return "reviewer"
return "reviewer", next(self.reports)
async def resume(self, **kwargs): async def resume(self, **kwargs):
if kwargs["result_type"] is ReviewReport: if kwargs["result_type"] is ReviewReport:
@@ -60,15 +59,15 @@ class FakePrompts:
def objects(rounds: int, reports: list[ReviewReport]): def objects(rounds: int, reports: list[ReviewReport]):
codex = FakeCodex(reports) opencode = FakeOpenCode(reports)
settings = SimpleNamespace( settings = SimpleNamespace(
implement_review_rounds=rounds, implement_review_rounds=rounds,
implement_model="model", implement_model="model",
implement_reasoning="high", implement_variant="high",
) )
deps = SimpleNamespace( deps = SimpleNamespace(
settings=settings, settings=settings,
codex=codex, opencode=opencode,
storage=FakeStorage(), storage=FakeStorage(),
prompts=FakePrompts(), prompts=FakePrompts(),
development=SimpleNamespace(description="python"), development=SimpleNamespace(description="python"),
@@ -95,12 +94,12 @@ def objects(rounds: int, reports: list[ReviewReport]):
message="", message="",
comment_id=1, 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: async def test_stops_after_clean_second_review() -> None:
clean = ReviewReport(summary="Ready", findings=[]) 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( _, report = await loop.run(
job, job,
workflow, workflow,
@@ -109,12 +108,12 @@ async def test_stops_after_clean_second_review() -> None:
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
) )
assert not report.has_serious_findings assert not report.has_serious_findings
assert codex.reviews == 2 assert opencode.reviews == 2
assert codex.revisions == 1 assert opencode.revisions == 1
async def test_does_not_make_unreviewed_final_revision() -> None: 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()] 3, [serious_report(), serious_report(), serious_report()]
) )
_, report = await loop.run( _, report = await loop.run(
@@ -125,5 +124,5 @@ async def test_does_not_make_unreviewed_final_revision() -> None:
AgentResult(summary_markdown="initial", tests=[]), AgentResult(summary_markdown="initial", tests=[]),
) )
assert report.has_serious_findings assert report.has_serious_findings
assert codex.reviews == 3 assert opencode.reviews == 3
assert codex.revisions == 2 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 == [] 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: def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet") monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
settings = Settings(_env_file=None) # type: ignore[call-arg] settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.install_scripts == ["python", "dotnet"] 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 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] root = Path(__file__).parents[1]
script = (root / "install-scripts" / "dotnet").read_text() script = (root / "install-scripts" / "dotnet").read_text()
assert 'DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' in script assert "export DOTNET_ROOT=" in script
assert 'NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' in script assert "/tmp/agentci-dotnet" not in script
http_cache = 'NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' assert "DEV_TOOLS_DIR/runtime/dotnet" in script
assert http_cache in script assert "NUGET_PACKAGES" in script
assert 'export HOME="$DOTNET_CLI_HOME"' 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 from pathlib import Path
import pytest 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) loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
assert loaded is not None assert loaded is not None
assert loaded.status is WorkflowStatus.COMPLETED 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: class FakeGitea:
def __init__(self) -> None: def __init__(self, permitted: bool = True) -> None:
self.permitted = permitted
self.comments: list[str] = [] self.comments: list[str] = []
async def has_write_permission(self, *_args):
return self.permitted
async def create_comment(self, _owner, _repo, _number, body): async def create_comment(self, _owner, _repo, _number, body):
self.comments.append(body) self.comments.append(body)
return len(self.comments) return len(self.comments)
@@ -72,6 +76,21 @@ async def test_authorized_command_is_queued() -> None:
assert "queued" in gitea.comments[0] 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: async def test_iterate_message_is_preserved_on_queued_job() -> None:
storage = FakeStorage() storage = FakeStorage()
container = SimpleNamespace(storage=storage, gitea=FakeGitea()) 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")}