56 changed files with 2500 additions and 1278 deletions
+10 -7
View File
@@ -3,12 +3,16 @@ AGENTCI_GITEA_URL=http://gitea:3000
AGENTCI_BOT_USERNAME=agentci AGENTCI_BOT_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
+100 -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,93 @@ one persistent Docker Compose service on the same Docker network as Gitea.
| PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. | | PR | `/agent 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. Commands
command gets separate queued and started comments. Final plans, PR results, are durably sequenced when their webhook arrives, then authorized and executed in that receive
failures, and remaining review findings are posted separately. order. Each command gets one Gitea comment, which is reconciled asynchronously through queued,
running, and terminal states. Deleted comments are rediscovered by their hidden marker or recreated.
## Deploy ## 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 +109,36 @@ 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 SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
instead of being replayed, because replaying a partially completed model turn transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
could duplicate changes. Git pushes are never forced. than reducer state. Control effects retry with bounded backoff. A delayed authorization blocks later
workflow execution but not later control work.
The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for
rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a
new plan or implementation. Queued jobs survive restart. A restart before `JobStarted` returns the
task to the queue; after `JobStarted`, the job is failed, its sessions are aborted, and execution is
never replayed because a partial model turn may already have changed files. Git pushes are never
forced.
## Development ## Development
@@ -132,7 +149,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)
+16
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(
@@ -98,6 +105,15 @@ class GiteaClient:
) )
return int(response.json()["id"]) return int(response.json()["id"])
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
response = await self._request(
"PATCH",
f"/repos/{owner}/{repo}/issues/comments/{comment_id}",
json={"body": body},
allow_not_found=True,
)
return response.status_code != 404
async def create_pull_request( async def create_pull_request(
self, self,
owner: str, owner: str,
+178 -162
View File
@@ -1,145 +1,177 @@
from __future__ import annotations from __future__ import annotations
import logging
import sqlite3 import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from agentci.adapters.database import Database, now from agentci.adapters.database import Database, now
from agentci.domain.models import Job, JobKind, JobStatus from agentci.adapters.state_persistence import (
insert_event as _insert_event,
)
from agentci.adapters.state_persistence import (
insert_state as _insert_state,
)
from agentci.adapters.state_persistence import (
insert_tasks as _insert_tasks,
)
from agentci.adapters.state_persistence import (
insert_workflow as _insert_workflow,
)
from agentci.adapters.state_persistence import (
optional_state as _optional_state,
)
from agentci.adapters.state_persistence import (
replace_state as _replace_state,
)
from agentci.adapters.state_persistence import (
state as _state,
)
from agentci.adapters.state_persistence import (
state_from_row as _state_from_row,
)
from agentci.domain.events import CommandReceived, JobEvent, WorkflowCreated
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState, next_state
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class EvolveResult:
state: JobState
duplicate: bool
@dataclass(frozen=True)
class ListenerTask:
id: int
job_id: str
source_event_id: str
listener: str
queue: str
attempts: int
class JobStore(Database): class JobStore(Database):
async def record_delivery(self, delivery_id: str, comment_id: int) -> bool: async def receive(
def record(connection: sqlite3.Connection) -> bool: self, event_id: str, job_id: str, incoming: CommandEvent
try: ) -> EvolveResult:
connection.execute( def operation(connection: sqlite3.Connection) -> EvolveResult:
"INSERT INTO deliveries VALUES (?, ?, ?)", connection.execute("BEGIN IMMEDIATE")
(delivery_id, comment_id, now()), duplicate = connection.execute(
) "SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
except sqlite3.IntegrityError: ).fetchone()
return False if duplicate:
return True state = _state(connection, duplicate["job_id"])
connection.commit()
return await self._run(record) return EvolveResult(state, True)
sequence = connection.execute(
async def enqueue(self, delivery_id: str, job: Job) -> bool: "SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
return await self._run(lambda connection: self._enqueue(connection, delivery_id, job)) ).fetchone()[0]
event = CommandReceived(
@staticmethod job_id=job_id,
def _enqueue(connection: sqlite3.Connection, delivery_id: str, job: Job) -> bool: delivery_id=incoming.delivery_id,
try: receive_sequence=sequence,
with connection: command_body=incoming.body,
connection.execute( target_key=incoming.target_key,
"INSERT INTO deliveries VALUES (?, ?, ?)", repo_owner=incoming.repo_owner,
(delivery_id, job.comment_id, now()), repo_name=incoming.repo_name,
) issue_number=incoming.issue_number,
connection.execute( pr_number=incoming.pr_number,
""" requester=incoming.requester,
INSERT INTO jobs ( comment_id=incoming.comment_id,
id, kind, target_key, repo_owner, repo_name, issue_number, )
pr_number, requester, message, comment_id, workflow_id, transition = next_state(None, event)
status, stage, created_at timestamp = now()
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) _insert_event(connection, event_id, event, timestamp)
""", _insert_state(connection, transition.state, timestamp)
( _insert_tasks(connection, event_id, transition, timestamp)
job.id,
job.kind,
job.target_key,
job.repo_owner,
job.repo_name,
job.issue_number,
job.pr_number,
job.requester,
job.message,
job.comment_id,
job.workflow_id,
job.status,
job.stage,
now(),
),
)
except sqlite3.IntegrityError:
return False
return True
async def claim_next(self) -> Job | None:
return await self._run(self._claim_next)
@staticmethod
def _claim_next(connection: sqlite3.Connection) -> Job | None:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT * FROM jobs WHERE status = ? ORDER BY created_at LIMIT 1",
(JobStatus.QUEUED,),
).fetchone()
if row is None:
connection.commit() connection.commit()
return None return EvolveResult(transition.state, False)
connection.execute(
"UPDATE jobs SET status = ?, stage = ?, started_at = ? WHERE id = ?",
(JobStatus.RUNNING, "starting", now(), row["id"]),
)
connection.commit()
return job_from_row(row, status=JobStatus.RUNNING, stage="starting")
async def update_job( return await self._run(operation)
self,
job_id: str,
*,
status: JobStatus | None = None,
stage: str | None = None,
error: str | None = None,
workflow_id: str | None = None,
) -> None:
updates: dict[str, object] = {}
if status is not None:
updates["status"] = status
if status in {JobStatus.SUCCEEDED, JobStatus.FAILED, JobStatus.REJECTED}:
updates["finished_at"] = now()
if stage is not None:
updates["stage"] = stage
if error is not None:
updates["error"] = error
if workflow_id is not None:
updates["workflow_id"] = workflow_id
await self._update("jobs", job_id, updates)
log.info(
"job state updated",
extra={
"operation": "job.update",
"job_id": job_id,
"workflow_id": workflow_id,
"stage": stage,
"status_code": status.value if status is not None else None,
},
)
async def set_job_comment(self, job_id: str, column: str, comment_id: int) -> None: async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
if column not in {"accepted_comment_id", "started_comment_id"}: def operation(connection: sqlite3.Connection) -> EvolveResult:
raise ValueError("Unsupported comment column") connection.execute("BEGIN IMMEDIATE")
if connection.execute(
"SELECT 1 FROM job_events WHERE event_id=?", (event_id,)
).fetchone():
state = _state(connection, event.job_id)
connection.commit()
return EvolveResult(state, True)
current = _state(connection, event.job_id)
transition = next_state(current, event)
timestamp = now()
_insert_event(connection, event_id, event, timestamp)
if isinstance(event, WorkflowCreated):
_insert_workflow(connection, event, timestamp)
_replace_state(connection, transition.state, current, timestamp)
_insert_tasks(connection, event_id, transition, timestamp)
connection.commit()
return EvolveResult(transition.state, False)
return await self._run(operation)
async def get_job_state(self, job_id: str) -> JobState | None:
return await self._run(lambda connection: _optional_state(connection, job_id))
async def claim_task(self, queue: str) -> ListenerTask | None:
return await self._run(lambda connection: _claim_task(connection, queue))
async def complete_task(self, task_id: int) -> None:
await self._run( await self._run(
lambda connection: connection.execute( lambda connection: connection.execute(
f"UPDATE jobs SET {column} = ? WHERE id = ?", (comment_id, job_id) "UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
(now(), task_id),
) )
) )
async def job_stage(self, job_id: str) -> str: async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
def select(connection: sqlite3.Connection) -> str: delay = min(2 ** min(attempts, 8), 300)
row = connection.execute("SELECT stage FROM jobs WHERE id=?", (job_id,)).fetchone() available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
return str(row["stage"]) if row else "unknown" await self._run(
lambda connection: connection.execute(
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
(available, error[:1000], task_id),
)
)
return await self._run(select) async def running_job_states(self) -> list[JobState]:
return await self._run(
lambda connection: [
_state_from_row(row)
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
]
)
async def recover_tasks(self) -> None:
def recover(connection: sqlite3.Connection) -> None:
with connection:
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND queue='control'"""
)
connection.execute(
"""UPDATE listener_tasks SET status='pending', started_at=NULL
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status='queued')"""
)
connection.execute(
"""UPDATE listener_tasks SET status='failed', finished_at=?,
error='Service restarted after execution began'
WHERE status='running' AND listener='execute' AND job_id IN
(SELECT id FROM jobs WHERE status<>'queued')""",
(now(),),
)
await self._run(recover)
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]: async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
return await self._run( return await self._run(
lambda connection: { lambda connection: {
value value
for row in connection.execute( for row in connection.execute(
""" "SELECT accepted_comment_id, started_comment_id FROM jobs "
SELECT accepted_comment_id, started_comment_id FROM jobs "WHERE repo_owner=? AND repo_name=? AND issue_number=?",
WHERE repo_owner=? AND repo_name=? AND issue_number=?
""",
(owner, repo, issue), (owner, repo, issue),
) )
for value in row for value in row
@@ -147,49 +179,33 @@ class JobStore(Database):
} }
) )
async def recover_running(self) -> list[Job]:
def recover(connection: sqlite3.Connection) -> list[Job]:
rows = connection.execute(
"SELECT * FROM jobs WHERE status=?", (JobStatus.RUNNING,)
).fetchall()
with connection:
connection.execute(
"""
UPDATE jobs SET status=?, stage=?, error=?, finished_at=?
WHERE status=?
""",
(
JobStatus.FAILED,
"interrupted",
"Service restarted during an active Codex turn",
now(),
JobStatus.RUNNING,
),
)
return [job_from_row(row) for row in rows]
return await self._run(recover) def _claim_task(connection: sqlite3.Connection, queue: str) -> ListenerTask | None:
connection.execute("BEGIN IMMEDIATE")
fifo = ""
def job_from_row( if queue == "jobs":
row: sqlite3.Row, fifo = """AND NOT EXISTS (
*, SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
status: JobStatus | None = None, AND earlier.status IN ('received', 'queued', 'running'))"""
stage: str | None = None, row = connection.execute(
) -> Job: f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
return Job( WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
id=row["id"], ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""",
kind=JobKind(row["kind"]), (queue, now()),
target_key=row["target_key"], ).fetchone()
repo_owner=row["repo_owner"], if row is None:
repo_name=row["repo_name"], connection.commit()
issue_number=row["issue_number"], return None
pr_number=row["pr_number"], changed = connection.execute(
requester=row["requester"], "UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
message=row["message"], "WHERE id=? AND status='pending'",
comment_id=row["comment_id"], (now(), row["id"]),
workflow_id=row["workflow_id"], )
status=status or JobStatus(row["status"]), if changed.rowcount != 1:
stage=stage or row["stage"], connection.rollback()
accepted_comment_id=row["accepted_comment_id"], return None
connection.commit()
return ListenerTask(
row["id"], row["job_id"], row["source_event_id"], row["listener"],
row["queue"], row["attempts"] + 1,
) )
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from time import monotonic
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, ValidationError
from agentci.adapters.codegraph import CodeGraphClient
from agentci.adapters.opencode_support import (
api_contract_ready,
directory_headers,
elapsed_ms,
error_message,
load_schema,
model_parts,
models_ready,
)
T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class OpenCodeError(RuntimeError):
pass
class OpenCodeClient:
def __init__(
self,
*,
base_url: str,
username: str,
password: str,
schemas_dir: Path,
health_directory: Path,
required_models: tuple[tuple[str, str | None], ...],
timeout_seconds: int,
codegraph: CodeGraphClient | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self.schemas_dir = schemas_dir
self.health_directory = health_directory
self.required_models = {
(*model_parts(model), variant) for model, variant in required_models
}
self._contract_valid: bool | None = None
self.timeout_seconds = timeout_seconds
self.codegraph = codegraph or CodeGraphClient()
self._active_sessions: dict[str, Path] = {}
self.client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
auth=httpx.BasicAuth(username, password),
timeout=httpx.Timeout(timeout_seconds, connect=10),
transport=transport,
)
async def close(self) -> None:
for session_id, workspace in tuple(self._active_sessions.items()):
await self.abort(session_id, workspace, best_effort=True)
await self.client.aclose()
async def ready(self) -> bool:
try:
health = await self.client.get("/global/health", timeout=10)
health.raise_for_status()
if health.json().get("healthy") is not True:
return False
if not str(health.json().get("version", "")).startswith("1."):
return False
if self._contract_valid is None:
document = await self.client.get("/doc", timeout=10)
document.raise_for_status()
self._contract_valid = api_contract_ready(document.json())
if not self._contract_valid:
return False
providers = await self.client.get(
"/provider", headers=directory_headers(self.health_directory), timeout=10
)
providers.raise_for_status()
return models_ready(providers.json(), self.required_models)
except (httpx.HTTPError, TypeError, ValueError):
return False
async def start(
self,
*,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> tuple[str, T]:
session_id = await self.create_session(workspace, schema_name)
result = await self.resume(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
return session_id, result
async def create_session(self, workspace: Path, title: str) -> str:
response = await self._request(
"POST",
"/session",
workspace=workspace,
json={"title": f"Agent CI: {title.removesuffix('.json')}"},
)
session_id = response.get("id")
if not isinstance(session_id, str) or not session_id:
raise OpenCodeError("OpenCode did not return a session ID")
return session_id
async def resume(
self,
*,
session_id: str,
prompt: str,
model: str,
variant: str | None,
workspace: Path,
schema_name: str,
result_type: type[T],
) -> T:
await self.codegraph.prepare(workspace)
self._active_sessions[session_id] = workspace
try:
return await self._prompt(
session_id=session_id,
workspace=workspace,
prompt=prompt,
model=model,
variant=variant,
schema_name=schema_name,
result_type=result_type,
)
except (asyncio.CancelledError, OpenCodeError):
await asyncio.shield(self.abort(session_id, workspace))
raise
finally:
self._active_sessions.pop(session_id, None)
async def _prompt(
self,
*,
session_id: str,
workspace: Path,
prompt: str,
model: str,
variant: str | None,
schema_name: str,
result_type: type[T],
) -> T:
try:
schema = load_schema(self.schemas_dir, schema_name)
except ValueError as exc:
raise OpenCodeError(str(exc)) from exc
provider_id, model_id = model_parts(model)
repair = "The previous response did not produce the required structured result. "
repair += "Return the requested result now without repeating repository work."
for attempt, message in enumerate((prompt, repair), start=1):
payload: dict[str, Any] = {
"model": {"providerID": provider_id, "modelID": model_id},
"agent": "build",
"parts": [{"type": "text", "text": message}],
"format": {"type": "json_schema", "schema": schema, "retryCount": 0},
}
if variant:
payload["variant"] = variant
started = monotonic()
extra = {"operation": "opencode.prompt", "attempt": attempt}
log.info("OpenCode turn started", extra=extra)
try:
response = await self._request(
"POST",
f"/session/{session_id}/message",
workspace=workspace,
json=payload,
)
except httpx.TimeoutException as exc:
await self.abort(session_id, workspace)
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
raise OpenCodeError(message) from exc
info = response.get("info")
if not isinstance(info, dict):
raise OpenCodeError("OpenCode response did not include assistant metadata")
error = error_message(info.get("error"))
structured = info.get("structured")
validation_failed = False
if structured is not None:
try:
result = result_type.model_validate(structured)
except ValidationError as exc:
error = f"structured result failed validation: {exc}"
validation_failed = True
else:
extra["duration_ms"] = elapsed_ms(started)
log.info("OpenCode turn completed", extra=extra)
return result
if attempt == 2 or (
error and "StructuredOutput" not in error and not validation_failed
):
raise OpenCodeError(f"OpenCode did not return a valid result: {error or 'missing'}")
log.warning("OpenCode structured result will be retried", extra=extra)
raise OpenCodeError("OpenCode did not return a valid result")
async def _request(
self, method: str, path: str, *,
workspace: Path,
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
try:
response = await self.client.request(
method, path, headers=directory_headers(workspace), json=json
)
response.raise_for_status()
payload = response.json()
except httpx.TimeoutException:
raise
except (httpx.HTTPError, ValueError) as exc:
detail = ""
if isinstance(exc, httpx.HTTPStatusError):
detail = f": {exc.response.text[-2000:]}"
message = f"OpenCode request failed: {method} {path}{detail}"
raise OpenCodeError(message) from exc
if not isinstance(payload, dict):
raise OpenCodeError(f"OpenCode returned an invalid response for {method} {path}")
return payload
async def abort(self, session_id: str, workspace: Path, *, best_effort: bool = False) -> None:
try:
response = await self.client.post(
f"/session/{session_id}/abort", headers=directory_headers(workspace), timeout=10
)
if response.is_success or response.status_code in {404, 409}:
return
response.raise_for_status()
except httpx.HTTPError as exc:
if best_effort:
log.warning("OpenCode session could not be aborted", exc_info=exc)
return
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
+77
View File
@@ -0,0 +1,77 @@
import json
from pathlib import Path
from time import monotonic
from typing import Any
def model_parts(model: str) -> tuple[str, str]:
provider, separator, model_id = model.partition("/")
if not separator or not provider or not model_id:
raise ValueError(f"OpenCode model must use provider/model format: {model}")
return provider, model_id
def error_message(error: object) -> str | None:
if not error:
return None
if isinstance(error, dict):
return str(error.get("name") or error.get("message") or error)
return str(error)
def elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())}
def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]:
try:
value = json.loads((schemas_dir / name).read_text())
except (OSError, ValueError) as exc:
raise ValueError(f"Cannot load result schema {name}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError(f"Result schema {name} is not a JSON object")
return value
def api_contract_ready(document: object) -> bool:
if not isinstance(document, dict) or not isinstance(document.get("paths"), dict):
return False
paths = document["paths"]
fixed = {"/global/health": "get", "/provider": "get", "/session": "post"}
if any(method not in paths.get(path, {}) for path, method in fixed.items()):
return False
session_paths = [path for path in paths if path.startswith("/session/{")]
has_message = any(
path.endswith("/message") and "post" in paths[path] for path in session_paths
)
has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths)
return has_message and has_abort
def models_ready(
payload: object, requirements: set[tuple[str, str, str | None]]
) -> bool:
if not isinstance(payload, dict):
return False
connected = set(payload.get("connected", []))
providers = {
item.get("id"): item
for item in payload.get("all", [])
if isinstance(item, dict) and isinstance(item.get("models"), dict)
}
for provider_id, model_id, variant in requirements:
provider = providers.get(provider_id)
if provider_id not in connected or not isinstance(provider, dict):
return False
model: Any = provider["models"].get(model_id)
if not isinstance(model, dict) or model.get("status") == "deprecated":
return False
if model.get("capabilities", {}).get("toolcall") is not True:
return False
if variant and variant not in model.get("variants", {}):
return False
return True
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import sqlite3
from agentci.domain.events import JobEvent, WorkflowCreated
from agentci.domain.models import JobKind, JobStatus
from agentci.domain.state_machine import JobState, Transition
def insert_event(connection: sqlite3.Connection, event_id: str, event: JobEvent, ts: str) -> None:
connection.execute(
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
(event_id, event.job_id, event.type, event.model_dump_json(), ts),
)
def insert_tasks(
connection: sqlite3.Connection, event_id: str, transition: Transition, timestamp: str
) -> None:
for ordinal, notification in enumerate(transition.notifications):
connection.execute(
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
transition.state.id,
event_id,
ordinal,
notification.listener,
notification.queue,
timestamp,
timestamp,
),
)
def _values(state: JobState) -> tuple[object, ...]:
return (
state.id, state.kind, state.target_key, state.repo_owner, state.repo_name,
state.issue_number, state.pr_number, state.requester, state.message, state.comment_id,
state.delivery_id, state.receive_sequence, state.command_body, state.workflow_id,
state.status, state.stage, state.error, state.runtime_session_id,
state.accepted_comment_id, state.comment_body,
)
def insert_state(connection: sqlite3.Connection, state: JobState, timestamp: str) -> None:
connection.execute(
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(*_values(state), timestamp),
)
def replace_state(
connection: sqlite3.Connection, state: JobState, previous: JobState, timestamp: str
) -> None:
started = (
timestamp
if previous.status is JobStatus.QUEUED and state.status is JobStatus.RUNNING
else None
)
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
finished = timestamp if previous.status not in terminal and state.status in terminal else None
connection.execute(
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
(*_values(state)[1:], started, finished, state.id),
)
def optional_state(connection: sqlite3.Connection, job_id: str) -> JobState | None:
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
return state_from_row(row) if row else None
def state(connection: sqlite3.Connection, job_id: str) -> JobState:
value = optional_state(connection, job_id)
if value is None:
raise KeyError(f"Unknown job {job_id}")
return value
def state_from_row(row: sqlite3.Row) -> JobState:
return JobState(
id=row["id"], kind=JobKind(row["kind"]) if row["kind"] else None,
target_key=row["target_key"], repo_owner=row["repo_owner"], repo_name=row["repo_name"],
issue_number=row["issue_number"], pr_number=row["pr_number"], requester=row["requester"],
message=row["message"], comment_id=row["comment_id"], delivery_id=row["delivery_id"],
receive_sequence=row["receive_sequence"], command_body=row["command_body"],
workflow_id=row["workflow_id"], status=JobStatus(row["status"]), stage=row["stage"],
error=row["error"], runtime_session_id=row["runtime_session_id"],
accepted_comment_id=row["accepted_comment_id"], comment_body=row["comment_body"],
)
def insert_workflow(connection: sqlite3.Connection, event: WorkflowCreated, ts: str) -> None:
workflow = event.workflow
connection.execute(
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
review_json, status, runtime, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
workflow.id, workflow.kind, workflow.repo_owner, workflow.repo_name,
workflow.issue_number, workflow.pr_number, workflow.base_sha, workflow.branch,
str(workflow.workspace_path), workflow.primary_session_id,
workflow.reviewer_session_id, workflow.artifact, workflow.review_json,
workflow.status, workflow.runtime, ts, ts,
),
)
+13 -2
View File
@@ -8,6 +8,15 @@ from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus
class WorkflowStore(Database): 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"}
+16 -48
View File
@@ -5,12 +5,10 @@ import hmac
import json import json
import logging import logging
from typing import Any from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind from agentci.domain.models import CommandEvent
from agentci.domain.models import CommandEvent, Job, JobStatus
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -54,7 +52,7 @@ async def webhook(request: Request) -> Response:
exc_info=exc, exc_info=exc,
) )
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
if event is None or event.requester == container.settings.bot_username: if event is None or event.requester.casefold() == container.settings.bot_username.casefold():
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name}) log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
return await _handle_command(container, event) return await _handle_command(container, event)
@@ -69,55 +67,25 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
extra = {"operation": "command.handle", "target": event.target_key} extra = {"operation": "command.handle", "target": event.target_key}
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)
if not event.delivery_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
log.info("agent command received", extra=extra) log.info("agent command received", extra=extra)
try: try:
command = parse_command(event.body) result = await container.state_machine.receive(event)
except CommandError as exc: except Exception:
log.info("agent command rejected: invalid syntax", extra=extra) log.exception("could not persist command", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id): raise
await container.gitea.create_comment( if result.duplicate:
event.repo_owner, event.repo_name, event.issue_number, str(exc) log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id})
)
return Response(status_code=status.HTTP_202_ACCEPTED)
if command is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
kind = resolve_job_kind(command, is_pull_request=event.is_pull_request)
except CommandError as exc:
log.info("agent command rejected: invalid target", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment(
event.repo_owner, event.repo_name, event.issue_number, str(exc)
)
return Response(status_code=status.HTTP_202_ACCEPTED)
job = Job(
id=str(uuid4()),
kind=kind,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
message=command.message,
comment_id=event.comment_id,
status=JobStatus.QUEUED,
stage="queued",
)
if not await container.storage.enqueue(event.delivery_id, job):
log.info("duplicate command ignored", extra={**extra, "job_id": job.id})
return Response(status_code=status.HTTP_200_OK) return Response(status_code=status.HTTP_200_OK)
log.info( log.info(
"agent job queued", "agent command persisted",
extra={**extra, "job_id": job.id, "stage": job.kind.value}, extra={
**extra,
"job_id": result.state.id,
"receive_sequence": result.state.receive_sequence,
},
) )
comment_id = await container.gitea.create_comment(
event.repo_owner,
event.repo_name,
event.issue_number,
f"Agent job `{job.id}` queued (`{job.kind}`).",
)
await container.storage.set_job_comment(job.id, "accepted_comment_id", comment_id)
return Response(status_code=status.HTTP_202_ACCEPTED) return Response(status_code=status.HTTP_202_ACCEPTED)
+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"
+23 -16
View File
@@ -4,13 +4,14 @@ 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
from agentci.state_machine import StateMachine
from agentci.worker import Worker from agentci.worker import Worker
from agentci.workflows.common import Dependencies from agentci.workflows.common import Dependencies
from agentci.workflows.context import ContextBuilder from agentci.workflows.context import ContextBuilder
@@ -24,11 +25,13 @@ class Container:
storage: Storage storage: Storage
gitea: GiteaClient gitea: GiteaClient
git: GitClient git: GitClient
codex: CodexClient opencode: OpenCodeClient
state_machine: StateMachine
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,9 +41,9 @@ 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()
state_machine = StateMachine(storage)
gitea = GiteaClient(settings.gitea_url, settings.gitea_token) gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
git = GitClient( git = GitClient(
gitea_url=settings.gitea_url, gitea_url=settings.gitea_url,
@@ -50,18 +53,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 +82,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,
@@ -86,11 +90,14 @@ async def build_container(settings: Settings) -> Container:
dispatcher = Dispatcher(dependencies) dispatcher = Dispatcher(dependencies)
worker = Worker( worker = Worker(
storage=storage, storage=storage,
state_machine=state_machine,
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,
bot_username=settings.bot_username,
) )
container = Container(settings, storage, gitea, git, codex, worker) container = Container(settings, storage, gitea, git, opencode, state_machine, worker)
log.info("container initialization completed", extra={"operation": "container.build"}) log.info("container initialization completed", extra={"operation": "container.build"})
return container return container
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
from agentci.domain.models import Workflow
class Event(BaseModel):
model_config = ConfigDict(frozen=True)
job_id: str
class CommandReceived(Event):
type: Literal["command_received"] = "command_received"
delivery_id: str
receive_sequence: int
command_body: str
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
comment_id: int
class PermissionGranted(Event):
type: Literal["permission_granted"] = "permission_granted"
class PermissionDenied(Event):
type: Literal["permission_denied"] = "permission_denied"
class JobStarted(Event):
type: Literal["job_started"] = "job_started"
class JobProgress(Event):
type: Literal["job_progress"] = "job_progress"
stage: str
class WorkflowCreated(Event):
type: Literal["workflow_created"] = "workflow_created"
workflow: Workflow
stage: str
class WorkflowLinked(Event):
type: Literal["workflow_linked"] = "workflow_linked"
workflow_id: str
stage: str
class RuntimeSessionLinked(Event):
type: Literal["runtime_session_linked"] = "runtime_session_linked"
session_id: str
class JobCompleted(Event):
type: Literal["job_completed"] = "job_completed"
comment_body: str
class JobRejected(Event):
type: Literal["job_rejected"] = "job_rejected"
reason: str
class JobFailed(Event):
type: Literal["job_failed"] = "job_failed"
error: str
stage: str
class ServiceRestarted(Event):
type: Literal["service_restarted"] = "service_restarted"
class CommentLinked(Event):
type: Literal["comment_linked"] = "comment_linked"
comment_id: int
JobEvent = Annotated[
CommandReceived
| PermissionGranted
| PermissionDenied
| JobStarted
| JobProgress
| WorkflowCreated
| WorkflowLinked
| RuntimeSessionLinked
| JobCompleted
| JobRejected
| JobFailed
| ServiceRestarted
| CommentLinked,
Field(discriminator="type"),
]
+3
View File
@@ -25,6 +25,7 @@ class JobKind(StrEnum):
class JobStatus(StrEnum): class JobStatus(StrEnum):
RECEIVED = "received"
QUEUED = "queued" QUEUED = "queued"
RUNNING = "running" RUNNING = "running"
SUCCEEDED = "succeeded" SUCCEEDED = "succeeded"
@@ -125,6 +126,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 +138,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
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
from agentci.domain.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobEvent,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import JobKind, JobStatus
class InvalidTransition(ValueError):
pass
@dataclass(frozen=True)
class Notification:
listener: str
queue: str = "control"
@dataclass(frozen=True)
class JobState:
id: str
target_key: str
repo_owner: str
repo_name: str
issue_number: int
pr_number: int | None
requester: str
comment_id: int
delivery_id: str
receive_sequence: int
command_body: str
kind: JobKind | None = None
message: str | None = None
status: JobStatus = JobStatus.RECEIVED
stage: str = "received"
error: str | None = None
workflow_id: str | None = None
runtime_session_id: str | None = None
accepted_comment_id: int | None = None
comment_body: str | None = None
@property
def is_pull_request(self) -> bool:
return self.pr_number is not None
@dataclass(frozen=True)
class Transition:
state: JobState
notifications: tuple[Notification, ...] = ()
RECONCILE = Notification("reconcile_comment")
def next_state(state: JobState | None, event: JobEvent) -> Transition:
if state is None:
if not isinstance(event, CommandReceived):
raise InvalidTransition("Only CommandReceived can create a job")
created = JobState(
id=event.job_id,
target_key=event.target_key,
repo_owner=event.repo_owner,
repo_name=event.repo_name,
issue_number=event.issue_number,
pr_number=event.pr_number,
requester=event.requester,
comment_id=event.comment_id,
delivery_id=event.delivery_id,
receive_sequence=event.receive_sequence,
command_body=event.command_body,
)
return Transition(created, (Notification("authorize"),))
if event.job_id != state.id:
raise InvalidTransition("Event job ID does not match state")
if isinstance(event, CommentLinked):
return Transition(replace(state, accepted_comment_id=event.comment_id))
if isinstance(event, ServiceRestarted):
if state.status is not JobStatus.RUNNING:
return Transition(state)
failed = replace(
state,
status=JobStatus.FAILED,
stage="interrupted",
error="Service restarted during an active OpenCode turn",
)
listeners = [Notification("abort_sessions"), RECONCILE]
if state.workflow_id:
listeners.insert(1, Notification("fail_workflow"))
return Transition(failed, tuple(listeners))
if state.status is JobStatus.RECEIVED:
return _received(state, event)
if state.status is JobStatus.QUEUED and isinstance(event, JobStarted):
return Transition(
replace(state, status=JobStatus.RUNNING, stage="starting"), (RECONCILE,)
)
if state.status is JobStatus.RUNNING:
return _running(state, event)
raise InvalidTransition(f"{event.type} is invalid while job is {state.status}")
def _received(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, PermissionDenied):
reason = "Agent command rejected: repository write permission is required."
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=reason),
(RECONCILE,),
)
if not isinstance(event, PermissionGranted):
raise InvalidTransition(f"{event.type} is invalid while job is received")
try:
command = parse_command(state.command_body)
if command is None:
raise CommandError("Invalid agent command.")
kind = resolve_job_kind(command, is_pull_request=state.is_pull_request)
except CommandError as exc:
return Transition(
replace(state, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
(RECONCILE,),
)
queued = replace(
state,
kind=kind,
message=command.message,
status=JobStatus.QUEUED,
stage="queued",
)
return Transition(queued, (Notification("execute", "jobs"), RECONCILE))
def _running(state: JobState, event: JobEvent) -> Transition:
if isinstance(event, JobProgress):
return Transition(replace(state, stage=event.stage))
if isinstance(event, WorkflowCreated):
return Transition(replace(state, workflow_id=event.workflow.id, stage=event.stage))
if isinstance(event, WorkflowLinked):
return Transition(replace(state, workflow_id=event.workflow_id, stage=event.stage))
if isinstance(event, RuntimeSessionLinked):
return Transition(replace(state, runtime_session_id=event.session_id))
if isinstance(event, JobCompleted):
return Transition(
replace(
state,
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body=event.comment_body,
),
(RECONCILE,),
)
if isinstance(event, (JobRejected, JobFailed)):
rejected = isinstance(event, JobRejected)
error = event.reason if rejected else event.error
stage = "rejected" if rejected else event.stage
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
listeners = [RECONCILE]
if state.workflow_id:
listeners.append(Notification("fail_workflow"))
return Transition(replace(state, status=status, stage=stage, error=error), tuple(listeners))
raise InvalidTransition(f"{event.type} is invalid while job is running")
def render_job_comment(state: JobState) -> str:
marker = f"<!-- agentci:job id={state.id} -->"
if state.status is JobStatus.SUCCEEDED and state.comment_body:
body = state.comment_body
elif state.status is JobStatus.REJECTED:
body = f"Agent job `{state.id}` was rejected: {state.error}"
elif state.status is JobStatus.FAILED:
body = f"Agent job `{state.id}` failed during `{state.stage}`: {state.error}"
else:
kind = state.kind.value if state.kind else "command"
body = f"Agent job `{state.id}` {state.status.value} (`{kind}`; stage: `{state.stage}`)."
return f"{marker}\n{body}"
+2
View File
@@ -49,3 +49,5 @@ def configure_logging() -> None:
root.handlers.clear() root.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;
@@ -0,0 +1,96 @@
PRAGMA foreign_keys=OFF;
ALTER TABLE jobs RENAME TO jobs_legacy;
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
kind TEXT,
target_key TEXT NOT NULL,
repo_owner TEXT NOT NULL,
repo_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
pr_number INTEGER,
requester TEXT NOT NULL,
message TEXT,
comment_id INTEGER NOT NULL,
delivery_id TEXT NOT NULL UNIQUE,
receive_sequence INTEGER NOT NULL UNIQUE,
command_body TEXT NOT NULL,
workflow_id TEXT REFERENCES workflows(id),
status TEXT NOT NULL,
stage TEXT NOT NULL,
error TEXT,
runtime_session_id TEXT,
accepted_comment_id INTEGER,
started_comment_id INTEGER,
comment_body TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
INSERT INTO jobs (
id, kind, target_key, repo_owner, repo_name, issue_number, pr_number,
requester, message, comment_id, delivery_id, receive_sequence, command_body,
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
started_comment_id, created_at, started_at, finished_at
)
SELECT j.id, j.kind, j.target_key, j.repo_owner, j.repo_name, j.issue_number,
j.pr_number, j.requester, j.message, j.comment_id,
COALESCE(d.delivery_id, 'legacy:' || j.id),
ROW_NUMBER() OVER (ORDER BY j.created_at, j.id),
'/agent ' || CASE j.kind
WHEN 'iterate_plan' THEN 'iterate'
WHEN 'iterate_implement' THEN 'iterate'
ELSE j.kind END || CASE WHEN j.message = '' THEN '' ELSE ' ' || j.message END,
j.workflow_id, j.status, j.stage, j.error, j.runtime_session_id,
j.accepted_comment_id, j.started_comment_id, j.created_at, j.started_at, j.finished_at
FROM jobs_legacy j
LEFT JOIN deliveries d ON d.comment_id = j.comment_id;
CREATE TABLE job_events (
event_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) DEFERRABLE INITIALLY DEFERRED,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO job_events(event_id, job_id, event_type, payload_json, created_at)
SELECT 'delivery:' || delivery_id, id, 'legacy', '{}', created_at FROM jobs;
CREATE TABLE listener_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES jobs(id),
source_event_id TEXT NOT NULL REFERENCES job_events(event_id),
ordinal INTEGER NOT NULL,
listener TEXT NOT NULL,
queue TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
available_at TEXT NOT NULL,
error TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
UNIQUE(source_event_id, listener, ordinal)
);
INSERT INTO listener_tasks(
job_id, source_event_id, ordinal, listener, queue, status, available_at, created_at
)
SELECT id, 'delivery:' || delivery_id, 0, 'execute', 'jobs', 'pending', created_at, created_at
FROM jobs WHERE status = 'queued';
INSERT INTO listener_tasks(
job_id, source_event_id, ordinal, listener, queue, status, available_at, created_at
)
SELECT id, 'delivery:' || delivery_id, 1, 'reconcile_comment', 'control', 'pending',
created_at, created_at
FROM jobs WHERE status = 'queued';
DROP TABLE jobs_legacy;
CREATE INDEX idx_jobs_queue ON jobs(status, receive_sequence);
CREATE INDEX idx_jobs_target ON jobs(target_key, status);
CREATE INDEX idx_listener_eligible ON listener_tasks(queue, status, available_at, id);
PRAGMA foreign_keys=ON;
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from agentci.domain.events import (
JobProgress,
RuntimeSessionLinked,
WorkflowCreated,
WorkflowLinked,
)
from agentci.domain.models import Workflow
from agentci.state_machine import StateMachine
class NullReporter:
final_body: str | None = None
async def progress(self, _stage: str) -> None:
pass
async def create_workflow(self, _workflow: Workflow, _stage: str) -> None:
pass
async def link_workflow(self, _workflow_id: str, _stage: str) -> None:
pass
async def link_runtime_session(self, _session_id: str) -> None:
pass
def finish(self, body: str) -> None:
self.final_body = body
_current: ContextVar[JobReporter | NullReporter | None] = ContextVar(
"job_reporter", default=None
)
class JobReporter:
def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None:
self.host = host
self.job_id = job_id
self.task_id = task_id
self.sequence = 0
self.final_body: str | None = None
async def progress(self, stage: str) -> None:
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
async def link_workflow(self, workflow_id: str, stage: str) -> None:
await self._emit(
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
)
async def link_runtime_session(self, session_id: str) -> None:
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
def finish(self, body: str) -> None:
self.final_body = body
async def _emit(self, event) -> None:
self.sequence += 1
await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event)
def bind_reporter(
reporter: JobReporter,
) -> Token[JobReporter | NullReporter | None]:
return _current.set(reporter)
def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None:
_current.reset(token)
def reporter() -> JobReporter | NullReporter:
return _current.get() or NullReporter()
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID, uuid5
from agentci.adapters.job_store import EvolveResult, JobStore
from agentci.domain.events import JobEvent
from agentci.domain.models import CommandEvent
from agentci.domain.state_machine import JobState
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
@dataclass(frozen=True)
class ReceiveResult:
state: JobState
duplicate: bool
class StateMachine:
def __init__(self, store: JobStore) -> None:
self.store = store
async def receive(self, incoming: CommandEvent) -> ReceiveResult:
job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id))
result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming)
return ReceiveResult(result.state, result.duplicate)
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
return await self.store.evolve(event_id, event)
async def get(self, job_id: str) -> JobState | None:
return await self.store.get_job_state(job_id)
+168 -111
View File
@@ -3,11 +3,28 @@ 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.job_store import ListenerTask
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.events import (
CommentLinked,
JobCompleted,
JobFailed,
JobStarted,
PermissionDenied,
PermissionGranted,
ServiceRestarted,
)
from agentci.domain.events import (
JobRejected as RejectedEvent,
)
from agentci.domain.models import JobStatus
from agentci.domain.state_machine import JobState, render_job_comment
from agentci.reporting import JobReporter, bind_reporter, reset_reporter
from agentci.state_machine import StateMachine
from agentci.workflows.common import JobRejected from agentci.workflows.common import JobRejected
from agentci.workflows.dispatcher import Dispatcher from agentci.workflows.dispatcher import Dispatcher
@@ -19,134 +36,174 @@ class Worker:
self, self,
*, *,
storage: Storage, storage: Storage,
state_machine: StateMachine,
gitea: GiteaClient, gitea: GiteaClient,
codex: CodexClient, opencode: OpenCodeClient,
dispatcher: Dispatcher, dispatcher: Dispatcher,
poll_seconds: float, poll_seconds: float,
workspaces_dir: Path,
bot_username: str,
) -> None: ) -> None:
self.storage = storage self.storage = storage
self.host = state_machine
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
self.bot_username = bot_username
async def run(self, stop: asyncio.Event) -> None: async def run(self, stop: asyncio.Event) -> None:
log.info("worker started", extra={"operation": "worker.run"}) await self._recover()
await self._report_interrupted() await asyncio.gather(self._loop("control", stop), self._loop("jobs", stop))
try:
while not stop.is_set():
if not await self.codex.login_ready():
log.warning(
"worker waiting for Codex authentication",
extra={"operation": "worker.poll"},
)
await self._wait(stop)
continue
job = await self.storage.claim_next()
if job is None:
await self._wait(stop)
continue
await self._run_job(job)
except Exception:
log.exception("worker stopped unexpectedly", extra={"operation": "worker.run"})
raise
finally:
log.info("worker stopped", extra={"operation": "worker.run"})
async def _run_job(self, job: Job) -> None: async def _loop(self, queue: str, stop: asyncio.Event) -> None:
extra = {"job_id": job.id, "target": job.target_key} while not stop.is_set():
log.info("job started", extra=extra) if queue == "jobs" and not await self.opencode.ready():
try: await self._wait(stop)
if job.accepted_comment_id is None: continue
accepted_id = await self.gitea.create_comment( task = await self.storage.claim_task(queue)
job.repo_owner, if task is None:
job.repo_name, await self._wait(stop)
job.issue_number, continue
f"Agent job `{job.id}` queued (`{job.kind}`).", try:
await self._handle(task)
except asyncio.CancelledError:
raise
except Exception as exc:
log.exception(
"listener failed",
extra={"task_id": task.id, "listener": task.listener, "queue": queue},
) )
await self.storage.set_job_comment( await self.storage.retry_task(task.id, task.attempts, _safe_error(exc))
job.id, "accepted_comment_id", accepted_id else:
) await self.storage.complete_task(task.id)
comment_id = await self.gitea.create_comment(
job.repo_owner, async def _handle(self, task: ListenerTask) -> None:
job.repo_name, state = await self.host.get(task.job_id)
job.issue_number, if state is None:
f"Agent job `{job.id}` started (`{job.kind}`).", return
) if task.listener == "authorize":
await self.storage.set_job_comment(job.id, "started_comment_id", comment_id) await self._authorize(task, state)
await self.dispatcher.dispatch(job) elif task.listener == "execute":
except JobRejected as exc: await self._execute(task, state)
await self._safe_update_job( elif task.listener == "reconcile_comment":
job, status=JobStatus.REJECTED, stage="rejected", error=str(exc) await self._reconcile(task, state)
) elif task.listener == "fail_workflow":
await self._safe_comment(job, f"Agent job `{job.id}` was rejected: {exc}") await self.storage.fail_job_workflow(state.id)
log.info("job rejected", extra={**extra, "stage": "rejected"}) elif task.listener == "abort_sessions":
except Exception as exc: await self._abort_job_sessions(state)
failed_stage = await self._safe_job_stage(job)
await self._safe_update_job(
job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc)
)
await self._safe_fail_workflow(job)
await self._safe_comment(
job,
f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}",
)
log.exception("job failed", extra={**extra, "stage": failed_stage})
else: else:
await self._safe_update_job( raise RuntimeError(f"Unknown listener {task.listener}")
job, status=JobStatus.SUCCEEDED, stage="completed"
)
log.info("job completed", extra=extra)
async def _report_interrupted(self) -> None: async def _authorize(self, task: ListenerTask, state: JobState) -> None:
jobs = await self.storage.recover_running() if state.status is not JobStatus.RECEIVED:
if jobs: return
log.warning( permitted = await self.gitea.has_write_permission(
"recovering interrupted jobs", state.repo_owner, state.repo_name, state.requester
extra={"operation": "worker.recover", "item_count": len(jobs)}, )
) event = (
for job in jobs: PermissionGranted(job_id=state.id)
await self._safe_fail_workflow(job) if permitted
await self._safe_comment( else PermissionDenied(job_id=state.id)
job, )
f"Agent job `{job.id}` failed because the service restarted during execution.", outcome = "permission-granted" if permitted else "permission-denied"
) await self.host.evolve(f"task:{task.id}:{outcome}", event)
async def _safe_comment(self, job: Job, body: str) -> None: async def _execute(self, task: ListenerTask, state: JobState) -> None:
if state.status is JobStatus.RUNNING:
await self.host.evolve(
f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id)
)
return
if state.status is not JobStatus.QUEUED:
return
result = await self.host.evolve(
f"task:{task.id}:started", JobStarted(job_id=state.id)
)
running = result.state
reporter = JobReporter(self.host, state.id, task.id)
token = bind_reporter(reporter)
try: try:
await self.gitea.create_comment( await self.dispatcher.dispatch(running)
job.repo_owner, job.repo_name, job.issue_number, body except JobRejected as exc:
await self.host.evolve(
f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc))
) )
except Exception: except Exception as exc:
log.exception("could not publish job status", extra={"job_id": job.id}) latest = await self.host.get(state.id)
stage = latest.stage if latest else running.stage
async def _safe_job_stage(self, job: Job) -> str: await self.host.evolve(
try: f"task:{task.id}:failed",
return await self.storage.job_stage(job.id) JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage),
except Exception:
log.exception("could not read failed job stage", extra={"job_id": job.id})
return job.stage or "unknown"
async def _safe_update_job(
self,
job: Job,
*,
status: JobStatus,
stage: str,
error: str | None = None,
) -> None:
try:
await self.storage.update_job(
job.id, status=status, stage=stage, error=error
) )
except Exception: else:
log.exception("could not persist job status", extra={"job_id": job.id}) await self.host.evolve(
f"task:{task.id}:completed",
JobCompleted(
job_id=state.id,
comment_body=reporter.final_body or "Agent job completed.",
),
)
finally:
reset_reporter(token)
async def _safe_fail_workflow(self, job: Job) -> None: async def _reconcile(self, task: ListenerTask, state: JobState) -> None:
try: latest = await self.host.get(state.id)
await self.storage.fail_job_workflow(job.id) if latest is None:
except Exception: return
log.exception("could not mark workflow failed", extra={"job_id": job.id}) body = render_job_comment(latest)
comment_id = latest.accepted_comment_id
if comment_id is not None and await self.gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
):
return
marker = f"<!-- agentci:job id={latest.id} -->"
matches = sorted(
comment.id
for comment in await self.gitea.issue_comments(
latest.repo_owner, latest.repo_name, latest.issue_number
)
if comment.body.startswith(marker)
and comment.author.casefold() == self.bot_username.casefold()
)
if matches:
comment_id = matches[0]
else:
comment_id = await self.gitea.create_comment(
latest.repo_owner, latest.repo_name, latest.issue_number, body
)
await self.host.evolve(
f"task:{task.id}:comment:{comment_id}",
CommentLinked(job_id=latest.id, comment_id=comment_id),
)
await self.gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
)
async def _recover(self) -> None:
await self.storage.recover_tasks()
for state in await self.storage.running_job_states():
await self.host.evolve(
f"recovery:{state.id}:service-restarted",
ServiceRestarted(job_id=state.id),
)
async def _abort_job_sessions(self, state: JobState) -> None:
sessions: set[tuple[str, Path]] = set()
workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None
if workflow:
sessions.update(
(session, workflow.workspace_path)
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
if session
)
elif state.runtime_session_id:
sessions.add(
(state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo")
)
for session, workspace in sessions:
await self.opencode.abort(session, workspace)
async def _wait(self, stop: asyncio.Event) -> None: async def _wait(self, stop: asyncio.Event) -> None:
with suppress(TimeoutError): with suppress(TimeoutError):
+1 -2
View File
@@ -1,2 +1 @@
"""Codex workflow orchestration.""" """OpenCode workflow orchestration."""
+5 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from agentci.domain.models import AgentResult, Job from agentci.domain.models import AgentResult, Job
from agentci.reporting import reporter
from agentci.workflows.common import Dependencies, JobRejected from agentci.workflows.common import Dependencies, JobRejected
@@ -20,14 +21,14 @@ class ChangeSet:
set_upstream: bool, set_upstream: bool,
commit_prefix: str, commit_prefix: str,
) -> str: ) -> str:
await self.deps.storage.update_job(job.id, stage="validating changes") await reporter().progress("validating changes")
if not await self.deps.git.has_changes(workspace): 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 reporter().progress("committing changes")
sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}") sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}")
await self.deps.storage.update_job(job.id, stage="pushing changes") await reporter().progress("pushing changes")
await self.deps.git.push(workspace, branch, set_upstream=set_upstream) await self.deps.git.push(workspace, branch, set_upstream=set_upstream)
return sha return sha
@@ -54,4 +55,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"
+23 -22
View File
@@ -1,7 +1,13 @@
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.reporting import reporter
from agentci.workflows.common import (
Dependencies,
report_for_prompt,
report_json,
required_session,
)
class CodeReviewLoop: class CodeReviewLoop:
@@ -18,10 +24,9 @@ class CodeReviewLoop:
) -> tuple[AgentResult, ReviewReport]: ) -> tuple[AgentResult, ReviewReport]:
report = ReviewReport(summary="", findings=[]) report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.implement_review_rounds): for round_index in range(self.deps.settings.implement_review_rounds):
await self.deps.storage.update_job( await reporter().progress(
job.id, f"reviewing implementation {round_index + 1}/"
stage=f"reviewing implementation {round_index + 1}/" f"{self.deps.settings.implement_review_rounds}"
f"{self.deps.settings.implement_review_rounds}",
) )
report = await self.once( report = await self.once(
workflow, workflow,
@@ -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
+13 -2
View File
@@ -3,14 +3,15 @@ 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
from agentci.prompts import PromptLibrary from agentci.prompts import PromptLibrary
from agentci.reporting import reporter
from agentci.workflows.context import ContextBuilder from agentci.workflows.context import ContextBuilder
@@ -18,13 +19,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
@@ -63,3 +70,7 @@ def report_for_prompt(report_json_value: str | None) -> str:
def agent_comment(kind: str, workflow_id: str, body: str) -> str: def agent_comment(kind: str, workflow_id: str, body: str) -> str:
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}" return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
async def finish_job(body: str) -> None:
reporter().finish(body)
+7 -3
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from agentci.domain.models import Job, JobKind from agentci.domain.models import JobKind
from agentci.domain.state_machine import JobState
from agentci.workflows.common import Dependencies from agentci.workflows.common import Dependencies
from agentci.workflows.implement import ImplementWorkflow from agentci.workflows.implement import ImplementWorkflow
from agentci.workflows.plan import PlanWorkflow from agentci.workflows.plan import PlanWorkflow
@@ -15,7 +17,7 @@ class Dispatcher:
def __init__(self, dependencies: Dependencies) -> None: def __init__(self, dependencies: Dependencies) -> None:
plan = PlanWorkflow(dependencies) plan = PlanWorkflow(dependencies)
pull_request = PullRequestWorkflow(dependencies) pull_request = PullRequestWorkflow(dependencies)
self.handlers = { self.handlers: dict[JobKind, Any] = {
JobKind.PLAN: plan.plan, JobKind.PLAN: plan.plan,
JobKind.DISCUSS: plan.discuss, JobKind.DISCUSS: plan.discuss,
JobKind.ITERATE_PLAN: plan.iterate, JobKind.ITERATE_PLAN: plan.iterate,
@@ -24,7 +26,9 @@ class Dispatcher:
JobKind.FIX: pull_request.fix, JobKind.FIX: pull_request.fix,
} }
async def dispatch(self, job: Job) -> None: async def dispatch(self, job: JobState) -> None:
if job.kind is None:
raise RuntimeError("Cannot dispatch an unparsed command")
extra = { extra = {
"operation": "workflow.dispatch", "operation": "workflow.dispatch",
"job_id": job.id, "job_id": job.id,
+16 -23
View File
@@ -9,12 +9,14 @@ from agentci.domain.models import (
WorkflowKind, WorkflowKind,
WorkflowStatus, WorkflowStatus,
) )
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment
from agentci.workflows.code_review import CodeReviewLoop from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import ( from agentci.workflows.common import (
Dependencies, Dependencies,
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job,
report_json, report_json,
review_markdown, review_markdown,
) )
@@ -36,7 +38,7 @@ class ImplementWorkflow:
f"{workflow_id[:8]}" f"{workflow_id[:8]}"
) )
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo" workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
await self.deps.storage.update_job(job.id, stage="cloning") await reporter().progress("cloning")
base_sha = await self.deps.git.clone( base_sha = await self.deps.git.clone(
job.repo_owner, job.repo_owner,
job.repo_name, job.repo_name,
@@ -54,15 +56,9 @@ class ImplementWorkflow:
base_sha=base_sha, base_sha=base_sha,
branch=branch, branch=branch,
) )
await self.deps.storage.create_workflow(workflow) await reporter().create_workflow(workflow, "installing development environment")
job.workflow_id = workflow.id
await self.deps.storage.update_job(
job.id, workflow_id=workflow.id, stage="installing development environment"
)
await self.deps.development.prepare(workspace) await self.deps.development.prepare(workspace)
await self.deps.storage.update_job( await reporter().progress("implementing")
job.id, workflow_id=workflow.id, stage="implementing"
)
context = await self.deps.context.issue_context( context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number job.repo_owner, job.repo_name, job.issue_number
) )
@@ -76,16 +72,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 reporter().link_runtime_session(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(
@@ -103,7 +102,7 @@ class ImplementWorkflow:
set_upstream=True, set_upstream=True,
commit_prefix="agent", commit_prefix="agent",
) )
await self.deps.storage.update_job(job.id, stage="creating pull request") await reporter().progress("creating pull request")
pull = await self.deps.gitea.create_pull_request( pull = await self.deps.gitea.create_pull_request(
job.repo_owner, job.repo_owner,
job.repo_name, job.repo_name,
@@ -122,17 +121,11 @@ class ImplementWorkflow:
f"{job.repo_name}/pulls/{pull.number}" f"{job.repo_name}/pulls/{pull.number}"
) )
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}" body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
await self.deps.gitea.create_comment( body = agent_comment("implementation", workflow.id, body)
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("implementation", workflow.id, body),
)
remaining = review_markdown(report) remaining = review_markdown(report)
if remaining: if remaining:
await self.deps.gitea.create_comment( body = f"{body}\n\n{remaining}"
job.repo_owner, job.repo_name, pull.number, remaining await finish_job(body)
)
async def _reject_duplicate(self, job: Job) -> None: async def _reject_duplicate(self, job: Job) -> None:
workflows = await self.deps.storage.implementation_workflows( workflows = await self.deps.storage.implementation_workflows(
+42 -54
View File
@@ -11,12 +11,15 @@ from agentci.domain.models import (
WorkflowKind, WorkflowKind,
WorkflowStatus, WorkflowStatus,
) )
from agentci.reporting import reporter
from agentci.workflows.common import ( from agentci.workflows.common import (
Dependencies, Dependencies,
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job,
report_for_prompt, report_for_prompt,
report_json, report_json,
required_session,
review_markdown, review_markdown,
) )
@@ -29,7 +32,7 @@ class PlanWorkflow:
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name) repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
workflow_id = str(uuid4()) workflow_id = str(uuid4())
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo" workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
await self.deps.storage.update_job(job.id, stage="cloning") await reporter().progress("cloning")
base_sha = await self.deps.git.clone( base_sha = await self.deps.git.clone(
job.repo_owner, job.repo_owner,
job.repo_name, job.repo_name,
@@ -45,9 +48,7 @@ class PlanWorkflow:
workspace_path=workspace, workspace_path=workspace,
base_sha=base_sha, base_sha=base_sha,
) )
await self.deps.storage.create_workflow(workflow) await reporter().create_workflow(workflow, "planning")
job.workflow_id = workflow.id
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="planning")
context = await self.deps.context.issue_context( context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number job.repo_owner, job.repo_name, job.issue_number
) )
@@ -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 reporter().link_runtime_session(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,39 +77,38 @@ 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 reporter().link_workflow(workflow.id, "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,
) )
await self.deps.gitea.create_comment( await finish_job(agent_comment("discussion", workflow.id, reply.markdown))
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("discussion", workflow.id, reply.markdown),
)
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:
raise JobRejected("The latest plan has no saved artifact.") raise JobRejected("The latest plan has no saved artifact.")
await self.deps.storage.update_job( await reporter().link_workflow(workflow.id, "iterating plan")
job.id, workflow_id=workflow.id, stage="iterating plan"
)
context = await self.deps.context.issue_context( context = await self.deps.context.issue_context(
job.repo_owner, job.repo_name, job.issue_number job.repo_owner, job.repo_name, job.issue_number
) )
@@ -116,12 +119,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,
@@ -134,10 +136,8 @@ class PlanWorkflow:
) -> ReviewReport: ) -> ReviewReport:
report = ReviewReport(summary="", findings=[]) report = ReviewReport(summary="", findings=[])
for round_index in range(self.deps.settings.plan_review_rounds): for round_index in range(self.deps.settings.plan_review_rounds):
await self.deps.storage.update_job( await reporter().progress(
job.id, f"reviewing plan {round_index + 1}/{self.deps.settings.plan_review_rounds}"
stage=f"reviewing plan {round_index + 1}/"
f"{self.deps.settings.plan_review_rounds}",
) )
report = await self._review(workflow, context, artifact) report = await self._review(workflow, context, artifact)
workflow.artifact = artifact.plan_markdown workflow.artifact = artifact.plan_markdown
@@ -152,12 +152,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 +170,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(
@@ -204,17 +204,11 @@ class PlanWorkflow:
workflow.review_json = report_json(report) workflow.review_json = report_json(report)
workflow.status = WorkflowStatus.COMPLETED workflow.status = WorkflowStatus.COMPLETED
await self.deps.storage.update_workflow(workflow) await self.deps.storage.update_workflow(workflow)
await self.deps.gitea.create_comment( body = agent_comment("plan", workflow.id, artifact.plan_markdown)
job.repo_owner,
job.repo_name,
job.issue_number,
agent_comment("plan", workflow.id, artifact.plan_markdown),
)
remaining = review_markdown(report) remaining = review_markdown(report)
if remaining: if remaining:
await self.deps.gitea.create_comment( body = f"{body}\n\n{remaining}"
job.repo_owner, job.repo_name, job.issue_number, remaining await finish_job(body)
)
async def _latest_plan(self, job: Job) -> Workflow: async def _latest_plan(self, job: Job) -> Workflow:
workflow = await self.deps.storage.latest_workflow( workflow = await self.deps.storage.latest_workflow(
@@ -239,9 +233,3 @@ class PlanWorkflow:
f"Issue plan iteration is disabled because agent PR #{pull.number} " 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
+22 -33
View File
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus
from agentci.reporting import reporter
from agentci.workflows.change_set import ChangeSet, result_comment from agentci.workflows.change_set import ChangeSet, result_comment
from agentci.workflows.code_review import CodeReviewLoop from agentci.workflows.code_review import CodeReviewLoop
from agentci.workflows.common import ( from agentci.workflows.common import (
Dependencies, Dependencies,
JobRejected, JobRejected,
agent_comment, agent_comment,
finish_job,
report_for_prompt, report_for_prompt,
report_json, report_json,
review_markdown, review_markdown,
@@ -28,6 +30,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(
@@ -37,16 +41,11 @@ class PullRequestWorkflow:
raise JobRejected("Implementation iteration requires an open pull request.") raise JobRejected("Implementation iteration requires an open pull request.")
if workflow.branch != pull.head_branch: if workflow.branch != pull.head_branch:
raise JobRejected("The pull request head branch no longer matches its workflow.") raise JobRejected("The pull request head branch no longer matches its workflow.")
await self.deps.storage.update_job( await reporter().link_workflow(workflow.id, "synchronizing branch")
job.id, workflow_id=workflow.id, stage="synchronizing branch"
)
job.workflow_id = workflow.id
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch) await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch)
await self.deps.storage.update_job( await reporter().progress("installing development environment")
job.id, stage="installing development environment"
)
await self.deps.development.prepare(workflow.workspace_path) await self.deps.development.prepare(workflow.workspace_path)
await self.deps.storage.update_job(job.id, stage="implementing iteration") await reporter().progress("implementing iteration")
prompt = self.deps.prompts.render( prompt = self.deps.prompts.render(
"implementation_iterate", "implementation_iterate",
context=context, context=context,
@@ -54,12 +53,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,
@@ -87,17 +85,13 @@ class PullRequestWorkflow:
workflow.artifact = result.model_dump_json() workflow.artifact = result.model_dump_json()
workflow.review_json = report_json(report) workflow.review_json = report_json(report)
await self.deps.storage.update_workflow(workflow) await self.deps.storage.update_workflow(workflow)
await self.deps.gitea.create_comment( body = agent_comment(
job.repo_owner, "iteration", workflow.id, result_comment(result, sha=sha)
job.repo_name,
pull_number,
agent_comment("iteration", workflow.id, result_comment(result, sha=sha)),
) )
remaining = review_markdown(report) remaining = review_markdown(report)
if remaining: if remaining:
await self.deps.gitea.create_comment( body = f"{body}\n\n{remaining}"
job.repo_owner, job.repo_name, pull_number, remaining await finish_job(body)
)
async def fix(self, job: Job) -> None: async def fix(self, job: Job) -> None:
pull_number = _pull_number(job) pull_number = _pull_number(job)
@@ -107,16 +101,14 @@ class PullRequestWorkflow:
if not pull.is_open: if not pull.is_open:
raise JobRejected("Fixes require an open pull request.") raise JobRejected("Fixes require an open pull request.")
workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo" workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo"
await self.deps.storage.update_job(job.id, stage="cloning pull request") await reporter().progress("cloning pull request")
await self.deps.git.clone( await self.deps.git.clone(
pull.head_owner, pull.head_owner,
pull.head_repo, pull.head_repo,
pull.head_branch, pull.head_branch,
workspace, workspace,
) )
await self.deps.storage.update_job( await reporter().progress("installing development environment")
job.id, stage="installing development environment"
)
await self.deps.development.prepare(workspace) await self.deps.development.prepare(workspace)
prompt = self.deps.prompts.render( prompt = self.deps.prompts.render(
"fix", "fix",
@@ -124,13 +116,15 @@ class PullRequestWorkflow:
message=job.message or "(address the pull request feedback)", message=job.message or "(address the pull request feedback)",
development_environment=self.deps.development.description, development_environment=self.deps.development.description,
) )
await self.deps.storage.update_job(job.id, stage="fixing") await reporter().progress("fixing")
_, result = await self.deps.codex.start( session_id = await self.deps.opencode.create_session(workspace, "fix")
await reporter().link_runtime_session(session_id)
result = await self.deps.opencode.resume(
session_id=session_id,
workspace=workspace, 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,
) )
@@ -142,12 +136,7 @@ class PullRequestWorkflow:
set_upstream=False, set_upstream=False,
commit_prefix="agent fix", commit_prefix="agent fix",
) )
await self.deps.gitea.create_comment( await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha)))
job.repo_owner,
job.repo_name,
pull_number,
agent_comment("fix", job.id, result_comment(result, sha=sha)),
)
def _pull_number(job: Job) -> int: def _pull_number(job: Job) -> int:
+13 -14
View File
@@ -28,15 +28,14 @@ def serious_report() -> ReviewReport:
) )
class FakeCodex: class FakeOpenCode:
def __init__(self, reports: list[ReviewReport]) -> None: 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"
+20
View File
@@ -27,9 +27,29 @@ def test_empty_install_scripts_disable_setup() -> None:
assert settings.install_scripts == [] assert settings.install_scripts == []
def test_defaults_research_variant_to_high(monkeypatch) -> None:
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.research_variant == "high"
def test_defaults_explore_agent_to_luna_low() -> None:
settings = Settings(_env_file=None) # type: ignore[call-arg]
assert settings.explore_model == "openai/gpt-5.6-luna"
assert settings.explore_variant == "low"
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None: 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]
+22
View File
@@ -0,0 +1,22 @@
import json
import httpx
import respx
from agentci.adapters.gitea import GiteaClient
@respx.mock
async def test_updates_issue_comment_by_id() -> None:
route = respx.patch(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
).mock(return_value=httpx.Response(200, json={"id": 17}))
client = GiteaClient("https://gitea.example", "secret")
try:
await client.update_comment("org", "repo", 17, "updated status")
finally:
await client.close()
assert route.called
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
+5 -5
View File
@@ -1,12 +1,12 @@
from pathlib import Path 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()
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import httpx
import pytest
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
def client(tmp_path: Path, status: int) -> OpenCodeClient:
return OpenCodeClient(
base_url="http://opencode:4096",
username="opencode",
password="secret",
schemas_dir=tmp_path,
health_directory=tmp_path,
required_models=(),
timeout_seconds=60,
transport=httpx.MockTransport(lambda _request: httpx.Response(status)),
)
@pytest.mark.parametrize("status", [200, 204, 404, 409])
async def test_absent_or_inactive_session_is_success(tmp_path: Path, status: int) -> None:
value = client(tmp_path, status)
await value.abort("session", tmp_path)
await value.close()
@pytest.mark.parametrize("status", [400, 429, 500])
async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -> None:
value = client(tmp_path, status)
with pytest.raises(OpenCodeError):
await value.abort("session", tmp_path)
await value.close()
+43
View File
@@ -0,0 +1,43 @@
import json
from pathlib import Path
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
root = Path(__file__).parents[1]
config = json.loads((root / "opencode" / "opencode.json").read_text())
assert "permission" not in config
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
assert "permission" not in config["agent"]["explore"]
assert config["agent"]["research"]["permission"] == {
"*": "deny",
"websearch": "allow",
"context7_*": "allow",
"gh_grep_*": "allow",
}
assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"]
assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp"
assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}"
assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}"
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
def test_compose_removes_codex_sandbox_exceptions() -> None:
root = Path(__file__).parents[1]
compose = (root / "compose.yaml").read_text()
dockerfile = (root / "Dockerfile").read_text()
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"):
assert forbidden not in compose
assert "no_cache" not in compose
assert "opencode_home:/var/lib/opencode" in compose
assert "HOME: /etc/opencode/home" in compose
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose
assert 'OPENCODE_ENABLE_EXA: "1"' in compose
assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose
assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose
assert compose.count("/run/agentci:mode=1777") == 2
assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose
assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile
assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile
+40
View File
@@ -0,0 +1,40 @@
from agentci.adapters.opencode_support import api_contract_ready, models_ready
def test_api_contract_requires_session_message_and_abort_routes() -> None:
valid = {
"paths": {
"/global/health": {"get": {}},
"/provider": {"get": {}},
"/session": {"post": {}},
"/session/{sessionID}/message": {"post": {}},
"/session/{sessionID}/abort": {"post": {}},
}
}
assert api_contract_ready(valid)
del valid["paths"]["/session/{sessionID}/abort"]
assert not api_contract_ready(valid)
def test_model_readiness_requires_tools_and_configured_variant() -> None:
payload = {
"connected": ["openai"],
"all": [
{
"id": "openai",
"models": {
"model": {
"status": "active",
"capabilities": {"toolcall": True},
"variants": {"high": {}},
}
},
}
],
}
assert models_ready(payload, {("openai", "model", "high")})
assert not models_ready(payload, {("openai", "model", "missing")})
payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False
assert not models_ready(payload, {("openai", "model", "high")})
+75
View File
@@ -0,0 +1,75 @@
from dataclasses import FrozenInstanceError
import pytest
from agentci.domain.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobStarted,
PermissionGranted,
ServiceRestarted,
)
from agentci.domain.models import JobKind, JobStatus
from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment
def received(body: str = "/agent plan message"):
return next_state(
None,
CommandReceived(
job_id="job",
delivery_id="delivery",
receive_sequence=1,
command_body=body,
target_key="org/repo:issue:1",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=None,
requester="alice",
comment_id=4,
),
)
def test_permission_parses_and_queues_execution() -> None:
transition = next_state(received().state, PermissionGranted(job_id="job"))
assert transition.state.status is JobStatus.QUEUED
assert transition.state.kind is JobKind.PLAN
assert transition.state.message == "message"
assert [(item.listener, item.queue) for item in transition.notifications] == [
("execute", "jobs"),
("reconcile_comment", "control"),
]
def test_invalid_syntax_is_rejected_after_permission() -> None:
transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job"))
assert transition.state.status is JobStatus.REJECTED
assert "Unknown" in (transition.state.error or "")
def test_running_completion_and_restart_are_explicit() -> None:
queued = next_state(received().state, PermissionGranted(job_id="job")).state
running = next_state(queued, JobStarted(job_id="job")).state
completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state
assert completed.status is JobStatus.SUCCEEDED
assert "# Result" in render_job_comment(completed)
assert next_state(completed, ServiceRestarted(job_id="job")).state == completed
def test_comment_link_is_allowed_on_terminal_state() -> None:
queued = next_state(received().state, PermissionGranted(job_id="job")).state
running = next_state(queued, JobStarted(job_id="job")).state
completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state
linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state
assert linked.accepted_comment_id == 9
def test_state_is_immutable_and_invalid_transitions_fail() -> None:
state = received().state
with pytest.raises(FrozenInstanceError):
state.stage = "changed" # type: ignore[misc]
with pytest.raises(InvalidTransition):
next_state(state, JobStarted(job_id="job"))
+71 -51
View File
@@ -1,16 +1,17 @@
import sqlite3
from pathlib import Path from pathlib import Path
import pytest import pytest
from agentci.adapters.storage import Storage from agentci.adapters.storage import Storage
from agentci.domain.models import ( from agentci.domain.events import (
Job, JobStarted,
JobKind, PermissionDenied,
JobStatus, PermissionGranted,
Workflow, WorkflowCreated,
WorkflowKind,
WorkflowStatus,
) )
from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus
from agentci.state_machine import StateMachine
@pytest.fixture @pytest.fixture
@@ -21,40 +22,60 @@ async def storage(tmp_path: Path) -> Storage:
return value return value
def make_job(job_id: str = "job-1") -> Job: def command(delivery: str, body: str = "/agent plan") -> CommandEvent:
return Job( return CommandEvent(
id=job_id, delivery_id=delivery,
kind=JobKind.PLAN, comment_id=int(delivery.rsplit("-", 1)[-1]),
target_key="alice/repo:issue:3",
repo_owner="alice", repo_owner="alice",
repo_name="repo", repo_name="repo",
issue_number=3, issue_number=3,
pr_number=None, pr_number=None,
requester="alice", requester="alice",
message="", body=body,
comment_id=10,
) )
async def test_enqueue_is_idempotent_and_claims_fifo(storage: Storage) -> None: async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None:
assert await storage.enqueue("delivery-1", make_job()) host = StateMachine(storage)
assert not await storage.enqueue("delivery-1", make_job("job-2")) first = await host.receive(command("delivery-1"))
claimed = await storage.claim_next() duplicate = await host.receive(command("delivery-1"))
assert claimed is not None second = await host.receive(command("delivery-2"))
assert claimed.id == "job-1"
assert claimed.status is JobStatus.RUNNING assert not first.duplicate
assert await storage.claim_next() is None assert duplicate.duplicate
assert duplicate.state.id == first.state.id
assert second.state.receive_sequence == first.state.receive_sequence + 1
async def test_recovers_running_job_as_failed(storage: Storage) -> None: async def test_received_job_blocks_later_execute_task(storage: Storage) -> None:
await storage.enqueue("delivery-1", make_job()) host = StateMachine(storage)
assert await storage.claim_next() is not None first = (await host.receive(command("delivery-1"))).state
recovered = await storage.recover_running() second = (await host.receive(command("delivery-2"))).state
assert [job.id for job in recovered] == ["job-1"] await host.evolve("grant-2", PermissionGranted(job_id=second.id))
assert await storage.claim_next() is None
assert await storage.claim_task("jobs") is None
await host.evolve("deny-1", PermissionDenied(job_id=first.id))
task = await storage.claim_task("jobs")
assert task is not None
assert task.job_id == second.id
async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) -> None: async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None:
host = StateMachine(storage)
state = (await host.receive(command("delivery-1"))).state
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
with sqlite3.connect(storage.database_path) as connection:
row = connection.execute(
"SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,)
).fetchone()
assert row is not None
assert row[0] is not None
assert row[1] is None
async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None:
workflow = Workflow( workflow = Workflow(
id="workflow-1", id="workflow-1",
kind=WorkflowKind.PLAN, kind=WorkflowKind.PLAN,
@@ -67,37 +88,36 @@ async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) ->
status=WorkflowStatus.COMPLETED, status=WorkflowStatus.COMPLETED,
) )
await storage.create_workflow(workflow) await storage.create_workflow(workflow)
await storage.fail_job_workflow("missing-job")
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.artifact == "# Plan" assert loaded.status is WorkflowStatus.COMPLETED
assert loaded.workspace_path == tmp_path / "repo"
async def test_tracks_operational_comments(storage: Storage) -> None: async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None:
await storage.enqueue("delivery-1", make_job()) host = StateMachine(storage)
await storage.set_job_comment("job-1", "accepted_comment_id", 21) state = (await host.receive(command("delivery-1"))).state
await storage.set_job_comment("job-1", "started_comment_id", 22) state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
assert await storage.operational_comment_ids("alice", "repo", 3) == {21, 22} state = (await host.evolve("start", JobStarted(job_id=state.id))).state
async def test_failed_followup_does_not_invalidate_completed_workflow(
storage: Storage, tmp_path: Path
) -> None:
workflow = Workflow( workflow = Workflow(
id="workflow-1", id="workflow-atomic",
kind=WorkflowKind.PLAN, kind=WorkflowKind.PLAN,
repo_owner="alice", repo_owner="alice",
repo_name="repo", repo_name="repo",
issue_number=3, issue_number=3,
workspace_path=tmp_path / "repo", workspace_path=tmp_path / "repo",
base_sha="abc", base_sha="abc",
status=WorkflowStatus.COMPLETED,
) )
await storage.create_workflow(workflow) result = await host.evolve(
job = make_job() "workflow-created",
job.workflow_id = workflow.id WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
await storage.enqueue("delivery-1", job) )
await storage.fail_job_workflow(job.id) assert result.state.workflow_id == workflow.id
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) assert await storage.get_workflow(workflow.id) is not None
assert loaded is not None
assert loaded.status is WorkflowStatus.COMPLETED
async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None:
with sqlite3.connect(storage.database_path) as connection:
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
assert "receive_sequence" in columns
assert "version" not in columns
+36 -76
View File
@@ -1,50 +1,29 @@
import hashlib import hashlib
import hmac import hmac
import json
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
class FakeStorage: class FakeHost:
def __init__(self) -> None: def __init__(self, duplicate: bool = False) -> None:
self.jobs = [] self.events = []
self.deliveries: set[str] = set() self.duplicate = duplicate
async def enqueue(self, delivery_id, job): async def receive(self, event):
if delivery_id in self.deliveries: self.events.append(event)
return False state = SimpleNamespace(id="job", receive_sequence=1)
self.deliveries.add(delivery_id) return SimpleNamespace(state=state, duplicate=self.duplicate)
self.jobs.append(job)
return True
async def record_delivery(self, delivery_id, _comment_id):
if delivery_id in self.deliveries:
return False
self.deliveries.add(delivery_id)
return True
async def set_job_comment(self, *_args):
return None
class FakeGitea:
def __init__(self) -> None:
self.comments: list[str] = []
async def create_comment(self, _owner, _repo, _number, body):
self.comments.append(body)
return len(self.comments)
def payload(body: str, *, is_pull: bool = False) -> dict: def payload(body: str, *, is_pull: bool = False) -> dict:
value = { value = {
"action": "created", "action": "created",
"comment": {"id": 8, "body": body, "user": {"login": "alice"}}, "comment": {"id": 8, "body": body, "user": {"login": "alice"}},
"repository": { "repository": {"name": "repo", "owner": {"login": "org"}},
"name": "repo",
"owner": {"login": "org"},
},
"issue": {"number": 4}, "issue": {"number": 4},
"is_pull": is_pull, "is_pull": is_pull,
} }
@@ -53,59 +32,40 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
return value return value
def test_extracts_pull_request_event() -> None: async def test_command_is_forwarded_without_parsing() -> None:
event = _event_from_payload("delivery", payload("/agent fix now", is_pull=True)) host = FakeHost()
assert event is not None
assert event.pr_number == 4
assert event.target_key == "org/repo:pr:4"
async def test_authorized_command_is_queued() -> None:
storage = FakeStorage()
gitea = FakeGitea()
container = SimpleNamespace(storage=storage, gitea=gitea)
event = _event_from_payload("delivery", payload("/agent plan consider migrations"))
assert event is not None
response = await _handle_command(container, event)
assert response.status_code == 202
assert len(storage.jobs) == 1
assert "queued" in gitea.comments[0]
async def test_iterate_message_is_preserved_on_queued_job() -> None:
storage = FakeStorage()
container = SimpleNamespace(storage=storage, gitea=FakeGitea())
event = _event_from_payload( event = _event_from_payload(
"delivery", "delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
payload(
"/agent iterate\n\nkeep the API stable\nlimit changes to the parser",
is_pull=True,
),
) )
assert event is not None assert event is not None
response = await _handle_command(SimpleNamespace(state_machine=host), event)
response = await _handle_command(container, event)
assert response.status_code == 202 assert response.status_code == 202
assert len(storage.jobs) == 1 assert host.events[0].body == "/agent iterate\n\nkeep raw body"
assert storage.jobs[0].message == (
"keep the API stable\nlimit changes to the parser"
) async def test_duplicate_returns_200() -> None:
event = _event_from_payload("delivery", payload("/agent plan"))
assert event is not None
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
assert response.status_code == 200
async def test_missing_delivery_is_rejected() -> None:
event = _event_from_payload("", payload("/agent plan"))
assert event is not None
with pytest.raises(HTTPException) as raised:
await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
assert raised.value.status_code == 400
async def test_non_command_is_ignored() -> None: async def test_non_command_is_ignored() -> None:
container = SimpleNamespace(storage=FakeStorage(), gitea=FakeGitea())
event = _event_from_payload("delivery", payload("ordinary discussion")) event = _event_from_payload("delivery", payload("ordinary discussion"))
assert event is not None assert event is not None
response = await _handle_command(container, event) response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
assert response.status_code == 204 assert response.status_code == 204
def test_rejects_bad_signature() -> None: def test_signature_validation() -> None:
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
assert valid_signature(b"secret", b"{}", signature)
assert not valid_signature(b"secret", b"{}", "bad") assert not valid_signature(b"secret", b"{}", "bad")
def test_accepts_valid_signature() -> None:
body = json.dumps(payload("ordinary comment")).encode()
signature = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
assert valid_signature(b"secret", body, signature)
+72
View File
@@ -0,0 +1,72 @@
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from agentci.domain.models import Workflow, WorkflowKind
from agentci.domain.state_machine import JobState
from agentci.worker import Worker, _safe_error
class FakeStorage:
def __init__(self, workflow=None) -> None:
self.workflow = workflow
async def get_workflow(self, _workflow_id):
return self.workflow
class FakeOpenCode:
def __init__(self) -> None:
self.aborted = set()
async def abort(self, session_id, workspace):
self.aborted.add((session_id, workspace))
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
return Worker(
storage=storage, # type: ignore[arg-type]
state_machine=SimpleNamespace(), # type: ignore[arg-type]
gitea=SimpleNamespace(), # type: ignore[arg-type]
opencode=opencode, # type: ignore[arg-type]
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
poll_seconds=1,
workspaces_dir=tmp_path,
bot_username="agentci",
)
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
workspace = tmp_path / "workflow" / "repo"
workflow = Workflow(
id="flow",
kind=WorkflowKind.IMPLEMENT,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=workspace,
base_sha="base",
primary_session_id="primary",
reviewer_session_id="reviewer",
)
opencode = FakeOpenCode()
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
cast(JobState, state)
)
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
opencode = FakeOpenCode()
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
cast(JobState, state)
)
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
def test_safe_error_is_single_line_and_bounded() -> None:
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
assert "\n" not in value
assert len(value) == 1000