diff --git a/.env.example b/.env.example index 4875001..0cb6c00 100644 --- a/.env.example +++ b/.env.example @@ -3,12 +3,12 @@ AGENTCI_GITEA_URL=http://gitea:3000 AGENTCI_BOT_USERNAME=agentci AGENTCI_BOT_NAME=Agent CI AGENTCI_BOT_EMAIL=agentci@localhost -AGENTCI_PLAN_MODEL=gpt-5.6-sol -AGENTCI_PLAN_REASONING=medium -AGENTCI_IMPLEMENT_MODEL=gpt-5.6-sol -AGENTCI_IMPLEMENT_REASONING=high -AGENTCI_RESEARCH_MODEL=gpt-5.6-luna -AGENTCI_RESEARCH_REASONING=high +OPENCODE_SERVER_USERNAME=opencode +AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol +AGENTCI_PLAN_VARIANT= +AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol +AGENTCI_IMPLEMENT_VARIANT= +AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna # Optional; Context7 works without a key at lower rate limits. AGENTCI_CONTEXT7_API_KEY= AGENTCI_PLAN_REVIEW_ROUNDS=4 @@ -19,5 +19,4 @@ AGENTCI_INSTALL_SCRIPTS= AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900 AGENTCI_PYTHON_VERSION=3.13 AGENTCI_DOTNET_CHANNEL=10.0 -CODEX_VERSION=0.144.6 CODEGRAPH_VERSION=1.3.1 diff --git a/Dockerfile b/Dockerfile index 4d767f8..16c35b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ 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 -RUN npm install --global \ - "@openai/codex@${CODEX_VERSION}" \ - "@colbymchenry/codegraph@${CODEGRAPH_VERSION}" +ARG OPENCODE_REFRESH +RUN test -n "$OPENCODE_REFRESH" \ + || { echo "OPENCODE_REFRESH is required; run scripts/build.sh" >&2; exit 1; } \ + && npm install --global \ + 'opencode-ai@^1' \ + "@colbymchenry/codegraph@${CODEGRAPH_VERSION}" \ + && version="$(opencode --version)" \ + && case "$version" in 1.*) ;; *) echo "Expected OpenCode 1.x, got $version" >&2; exit 1;; esac FROM ghcr.io/astral-sh/uv:0.8.14 AS uv @@ -19,30 +23,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ UV_LINK_MODE=copy \ UV_NO_DEV=1 \ CODEGRAPH_TELEMETRY=0 \ - CODEX_HOME=/var/lib/codex \ XDG_CONFIG_HOME=/run/agentci \ PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin RUN apt-get update \ && apt-get install --yes --no-install-recommends \ - adduser bubblewrap ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \ + adduser ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \ libicu72 libssl3 libstdc++6 zlib1g \ && rm -rf /var/lib/apt/lists/* \ && /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \ - && mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \ - && chown agentci:agentci /run/agentci \ - && chmod u+s /usr/bin/bwrap + && mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \ + /etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \ + && chmod 0555 /etc/opencode/home /etc/opencode/home/.opencode \ + /etc/opencode/xdg /etc/opencode/xdg/opencode \ + && chown agentci:agentci /run/agentci COPY --from=uv /uv /uvx /usr/local/bin/ -COPY --from=tea /bin/tea /usr/local/bin/tea -COPY --from=codex /usr/local/bin/node /usr/local/bin/node -COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex -COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry -RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \ - && ln -s \ - /usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \ - /usr/local/bin/codex-linux-sandbox \ - && ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \ +COPY --from=tea /bin/tea /usr/local/libexec/tea +COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node +COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode +COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry +RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \ /usr/local/bin/codegraph WORKDIR /opt/agentci @@ -50,16 +51,25 @@ COPY pyproject.toml uv.lock README.md ./ COPY src ./src COPY scripts ./scripts COPY install-scripts /etc/agentci/install-scripts -COPY codex/config.toml /etc/codex/config.toml +COPY opencode /etc/opencode RUN chmod 0755 \ /opt/agentci/scripts/entrypoint.sh \ /opt/agentci/scripts/gitea-askpass.sh \ - /usr/local/bin/tea \ - /usr/local/bin/codex \ + /opt/agentci/scripts/tea.sh \ + /usr/local/libexec/tea \ + /usr/local/bin/opencode \ /usr/local/bin/codegraph \ + && ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \ && uv sync --frozen --no-dev \ - && chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex + && chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/opencode \ + && opencode --version \ + && OPENCODE_CONFIG=/etc/opencode/opencode.json \ + OPENCODE_DISABLE_PROJECT_CONFIG=1 \ + OPENCODE_PURE=1 \ + AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \ + CONTEXT7_API_KEY= \ + opencode debug config >/dev/null USER agentci EXPOSE 8080 diff --git a/README.md b/README.md index 3358582..014a768 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Agent CI -Agent CI is a private Gitea webhook host that turns issue and pull-request -comments into resumable Codex planning and implementation workflows. It runs as -one persistent Docker Compose service on the same Docker network as Gitea. +Agent CI is a private Gitea webhook host that turns issue and pull-request comments into resumable +OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a +private OpenCode server on the same Docker network as Gitea. ## Commands @@ -15,68 +15,89 @@ one persistent Docker Compose service on the same Docker network as Gitea. | PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. | | PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. | -Anyone who can comment on an issue or pull request can enqueue commands. Each -command gets separate queued and started comments. Final plans, PR results, -failures, and remaining review findings are posted separately. +The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Each +accepted command gets separate queued and started comments. Final plans, PR results, failures, and +remaining review findings are posted separately. ## Deploy 1. Create a Gitea bot user with repository read/write access and an API token. -2. Copy `.env.example` to `.env` and set the external network and internal - Gitea URL. -3. Create `secrets/gitea_token` containing the bot token and - `secrets/webhook_secret` containing a high-entropy webhook secret. -4. Build and start the service: +2. Copy `.env.example` to `.env` and set the external network, Gitea URL, and provider-qualified + OpenCode models. +3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and + `secrets/opencode_server_password`. Use high-entropy values for both secret/password files. +4. Build the image: ```sh - docker compose up --build -d + ./scripts/build.sh ``` -5. Authenticate Codex interactively in the persistent container: +5. Authenticate the configured OpenCode providers before starting the persistent server: ```sh - docker compose exec agentci codex login --device-auth - docker compose exec agentci codex login status + docker compose run --rm opencode opencode auth login + docker compose run --rm opencode opencode auth list ``` -6. In Gitea, create a JSON webhook targeting - `http://agentci:8080/webhooks/gitea`. Set the same secret and subscribe to - issue comments, PR timeline comments, and PR review comments. +6. Start the services: -`/health/live` reports process health. `/health/ready` returns 503 until Codex -authentication is usable. The worker leaves jobs queued while authentication is -missing. + ```sh + docker compose up --no-build -d + ``` -## Configuration +7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same + webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments. -Model, reasoning effort, review-pass counts, bot identity, branch prefix, and -turn timeout use `AGENTCI_` environment variables. Defaults are shown in -`.env.example`. Gitea credentials and webhook secrets are intentionally -file-based Compose secrets. +OpenCode caches provider state. After adding or changing authentication on an already running +deployment, run the one-off `auth login` command above and then `docker compose restart opencode`. -Every planning and implementation session can delegate external research to a -read-only `research` subagent. It defaults to `gpt-5.6-luna` with high reasoning -and has public network access, live web search, Context7 documentation lookup, -and `gh_grep` public GitHub code search. Configure its model and effort with -`AGENTCI_RESEARCH_MODEL` and `AGENTCI_RESEARCH_REASONING`. Context7 works -without authentication at lower rate limits; set the optional -`AGENTCI_CONTEXT7_API_KEY` for authenticated usage. The key is passed only to -Codex's Context7 MCP transport and is excluded from agent shell environments. +`/health/live` reports process health. `/health/ready` returns 503 until the OpenCode server is +healthy and every configured model exists, supports tool calls, accepts its configured variant, and +has a connected provider. The worker leaves jobs queued while the runtime is unavailable. -Planning, implementation, and all review sessions also receive the local -CodeGraph MCP server for repository structure, symbol relationships, and change -impact. Agent CI initializes or refreshes the index before every Codex turn and -locally excludes `.codegraph/` from Git. The research subagent intentionally -does not receive CodeGraph. +## OpenCode -### Development environments +Models use OpenCode's `provider/model` format. Planning, implementation, and research can use +different providers. Optional `AGENTCI_PLAN_VARIANT` and `AGENTCI_IMPLEMENT_VARIANT` values are +passed directly to OpenCode for providers that support variants. -`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development -environment installers. The supplied `python` and `dotnet` scripts install only -their runtimes; they are ordinary scripts that can be replaced or removed. -Implementation agents remain responsible for restoring project dependencies -and selecting build/test commands. Configure the supplied scripts with -`AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`: +`scripts/build.sh` supplies a unique required cache key on every invocation. The image then runs +`npm install -g 'opencode-ai@^1'`, verifies the installed major version, and prints it. Compose also +requests a no-cache build. A direct build without `OPENCODE_REFRESH` fails rather than silently +reusing an old OpenCode installation layer. This is intentionally fresh rather than reproducible. +Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built. + +The trusted configuration is `opencode/opencode.json`. It grants `permission: "allow"` globally +and to every built-in or custom agent that Agent CI can invoke. Repository-local OpenCode config +and plugins are disabled so a clone cannot replace the service policy. OpenCode's 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 has the same unrestricted permissions as every other agent. Its prompt +asks it to focus on external evidence, but this is guidance rather than an isolation boundary. +Agent CI initializes or refreshes CodeGraph before every turn and locally excludes `.codegraph/` +from Git. + +### Security boundary + +OpenCode does not provide an OS sandbox. It can execute shell commands, edit Git metadata and +environment files, access external directories, use loopback and network services, bind ports, and +invoke subagents without approval. It can access every shared workspace, development tool, runtime +credential, and mounted file readable by the non-root container user. Environment filtering is +only accidental-exposure hygiene and cannot protect readable files from shell commands. + +Docker remains the OS boundary. The services run as non-root without added capabilities, +privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only +host bind mount is the read-only installer directory; there are no +writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password +protected, and is reachable by Agent CI over an internal Compose network. + +## Development environments + +`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development environment installers. +The supplied `python` and `dotnet` scripts install only their runtimes; they can be replaced or +removed. Configure them with `AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`: ```env AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools @@ -84,44 +105,29 @@ AGENTCI_PYTHON_VERSION=3.13 AGENTCI_DOTNET_CHANNEL=10.0 ``` -Every name resolves to an executable file in `install-scripts/`, mounted -read-only at `/etc/agentci/install-scripts`. Names cannot contain paths and -duplicates are rejected. See `install-scripts/README.md` for the script contract. +Every name resolves to a file in `install-scripts/`, mounted read-only at +`/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers +run after each implementation clone or branch sync and fail the job on an unknown script, timeout, +or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain +trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or +modify them through its unrestricted shell. See `install-scripts/README.md` for the script contract. -Installers run in order after each implementation clone or branch sync and fail -the job on an unknown script, timeout, or non-zero exit. They receive no AgentCI -or Gitea secret values in their environment, but remain trusted operator code -running as the service user. Tools persist under `/var/lib/agentci/dev-tools`; -its `bin` directory is added to implementation agents' `PATH` with read-only -sandbox access. The agents can use those tools for builds and validation, but -Agent CI does not impose host-side build commands. - -Planning/review commands can only read their workflow clone and have no shell -network access. Implementation/fix commands can edit the clone but cannot -modify `.git`; they can reach public internet destinations while private and -loopback destinations remain blocked. Codex's interactive Git trust check is -skipped because every turn runs non-interactively against a service-owned clone; -the configured filesystem and network permissions still apply. Git credentials -exist only in the service-owned clone/push subprocess and are not inherited by -Codex turns. The image and Compose capability/security settings let the non-root -service create Codex's nested `bwrap` sandbox. They follow Codex's secure -devcontainer pattern instead of making the service container privileged. Do not -remove Codex's configured filesystem and network restrictions. In particular, -`systempaths=unconfined` removes Docker's outer masked `/proc` subpaths so the -nested user/PID namespace can mount its own procfs. Runtimes such as CoreCLR -require `/proc/self/maps`; Codex still controls visibility through the fresh -procfs and its filesystem policy. +Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This +keeps workflow behavior deterministic, but unrestricted OpenCode is not prevented from running Git +commands itself. ## State and recovery -The `agentci_data` volume contains SQLite, persistent workflow clones, and -installed development runtimes. -`codex_home` contains login state and resumable Codex sessions. Both are kept -indefinitely and should be backed up together. +The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes. +The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable +sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to +`opencode_home`. Back up both persistent volumes together. -Queued jobs survive restart. An in-progress job is marked failed after restart -instead of being replayed, because replaying a partially completed model turn -could duplicate changes. Git pushes are never forced. +The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for +rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a +new plan or implementation. Queued jobs survive restart. An in-progress job is aborted in OpenCode +and marked failed instead of being replayed because a partial model turn may already have changed +files. Git pushes are never forced. ## Development @@ -132,7 +138,9 @@ uv sync uv run ruff check . uv run pyright uv run pytest +docker compose config +./scripts/build.sh ``` -The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON -schemas live outside Python so orchestration modules remain small and readable. +The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside +Python so orchestration modules remain small and readable. diff --git a/codex/config.toml b/codex/config.toml deleted file mode 100644 index 6036cde..0000000 --- a/codex/config.toml +++ /dev/null @@ -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" diff --git a/compose.yaml b/compose.yaml index 6906c21..d3c3af2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,38 +1,29 @@ services: agentci: + image: agentci:local build: context: . + no_cache: true args: - CODEX_VERSION: ${CODEX_VERSION:-0.144.6} CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1} + OPENCODE_REFRESH: ${OPENCODE_REFRESH:-} TEA_VERSION: ${TEA_VERSION:-0.14.2} restart: unless-stopped - # Codex applies its own bwrap sandbox inside this otherwise unprivileged container. - cap_add: - - SYS_ADMIN - - SYS_CHROOT - - SETUID - - SETGID - - SYS_PTRACE - - NET_ADMIN - - NET_RAW - security_opt: - - seccomp=unconfined - - apparmor=unconfined - # Docker's masked /proc paths prevent Codex's user namespace from mounting fresh procfs. - - systempaths=unconfined + depends_on: + opencode: + condition: service_healthy environment: AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000} AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci} AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI} AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost} - AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-gpt-5.6-sol} - AGENTCI_PLAN_REASONING: ${AGENTCI_PLAN_REASONING:-medium} - AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-gpt-5.6-sol} - AGENTCI_IMPLEMENT_REASONING: ${AGENTCI_IMPLEMENT_REASONING:-high} - AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-gpt-5.6-luna} - AGENTCI_RESEARCH_REASONING: ${AGENTCI_RESEARCH_REASONING:-high} - AGENTCI_CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-} + AGENTCI_OPENCODE_URL: http://opencode:4096 + AGENTCI_OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode} + AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-openai/gpt-5.6-sol} + AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-} + AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol} + AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-} + AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna} AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4} AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3} AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600} @@ -43,26 +34,78 @@ services: secrets: - gitea_token - webhook_secret + - opencode_server_password volumes: - agentci_data:/var/lib/agentci - - codex_home:/var/lib/codex - ./install-scripts:/etc/agentci/install-scripts:ro + tmpfs: + - /run/agentci expose: - "8080" networks: - gitea + - agentci_control + + opencode: + image: agentci:local + command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"] + restart: unless-stopped + environment: + HOME: /etc/opencode/home + XDG_CONFIG_HOME: /etc/opencode/xdg + XDG_DATA_HOME: /var/lib/opencode/data + XDG_CACHE_HOME: /var/lib/opencode/cache + XDG_STATE_HOME: /var/lib/opencode/state + OPENCODE_CONFIG: /etc/opencode/opencode.json + OPENCODE_DISABLE_PROJECT_CONFIG: "1" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "1" + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1" + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1" + OPENCODE_DISABLE_CLAUDE_CODE: "1" + OPENCODE_DISABLE_AUTOUPDATE: "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_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna} + 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 + expose: + - "4096" + networks: + - gitea + - agentci_control + healthcheck: + test: ["CMD-SHELL", "curl --fail --silent --user \"$${OPENCODE_SERVER_USERNAME}:$$(cat $${OPENCODE_SERVER_PASSWORD_FILE})\" http://127.0.0.1:4096/global/health >/dev/null"] + interval: 10s + timeout: 5s + start_period: 20s + retries: 6 secrets: gitea_token: file: ./secrets/gitea_token webhook_secret: file: ./secrets/webhook_secret + opencode_server_password: + file: ./secrets/opencode_server_password volumes: agentci_data: - codex_home: + opencode_home: networks: gitea: external: true name: ${GITEA_NETWORK:-gitea} + agentci_control: + internal: true diff --git a/install-scripts/README.md b/install-scripts/README.md index b451c44..140c2fc 100644 --- a/install-scripts/README.md +++ b/install-scripts/README.md @@ -18,10 +18,9 @@ files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/b directory is prepended to implementation agents' `PATH`. Environment changes made by a script do not persist into implementation turns. -The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable -.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a -normal user home and receive read-only access to installed tools, so runtime caches cannot live -beside the SDK. +The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state +and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only +so it cannot be used to persist global agent configuration. The configured scripts run in list order after the repository is cloned (or an existing agent PR branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and diff --git a/install-scripts/dotnet b/install-scripts/dotnet index 0a1e6e6..6f720f8 100644 --- a/install-scripts/dotnet +++ b/install-scripts/dotnet @@ -6,6 +6,7 @@ set -eu install_dir="$DEV_TOOLS_DIR/dotnet" bin_dir="$DEV_TOOLS_DIR/bin" +runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet" installer=$(mktemp) trap 'rm -f "$installer"' EXIT @@ -25,9 +26,9 @@ PYTHON printf '%s\n' \ '#!/bin/sh' \ "export DOTNET_ROOT='$install_dir'" \ - 'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \ - 'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \ - 'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \ + "export DOTNET_CLI_HOME='$runtime_dir/home'" \ + "export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \ + "export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \ 'export HOME="$DOTNET_CLI_HOME"' \ 'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \ 'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \ diff --git a/opencode/AGENTS.md b/opencode/AGENTS.md new file mode 100644 index 0000000..d5c1097 --- /dev/null +++ b/opencode/AGENTS.md @@ -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. diff --git a/opencode/opencode.json b/opencode/opencode.json new file mode 100644 index 0000000..7f55636 --- /dev/null +++ b/opencode/opencode.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://opencode.ai/config.json", + "autoupdate": false, + "share": "disabled", + "subagent_depth": 1, + "instructions": ["/etc/opencode/AGENTS.md"], + "permission": "allow", + "agent": { + "build": { + "permission": "allow" + }, + "plan": { + "permission": "allow" + }, + "general": { + "permission": "allow" + }, + "explore": { + "permission": "allow" + }, + "research": { + "description": "Research current documentation, web evidence, and public code examples.", + "mode": "subagent", + "model": "{env:AGENTCI_RESEARCH_MODEL}", + "permission": "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 + } + } +} diff --git a/pyproject.toml b/pyproject.toml index a97a6c0..8503baa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "agentci" version = "0.1.0" -description = "A Gitea webhook host that orchestrates resumable Codex workflows" +description = "A Gitea webhook host that orchestrates resumable OpenCode workflows" readme = "README.md" requires-python = ">=3.13" dependencies = [ diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..006368d --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$script_dir/.." + +export OPENCODE_REFRESH="$(date +%s)-$$" +exec docker compose build "$@" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 550333e..0e63976 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,12 +1,18 @@ #!/bin/sh set -eu -python -c ' +if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then + export OPENCODE_SERVER_PASSWORD + OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE") +fi + +if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then + python -c ' import json import os from pathlib import Path -config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea" +config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea" config_dir.mkdir(parents=True, exist_ok=True) config_path = config_dir / "config.yml" token_path = Path( @@ -22,5 +28,6 @@ login = { config_path.write_text(json.dumps({"logins": [login], "preferences": {}})) config_path.chmod(0o600) ' +fi exec "$@" diff --git a/scripts/tea.sh b/scripts/tea.sh new file mode 100644 index 0000000..521b129 --- /dev/null +++ b/scripts/tea.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}" +exec /usr/local/libexec/tea "$@" diff --git a/src/agentci/__init__.py b/src/agentci/__init__.py index 65d57b9..80392d7 100644 --- a/src/agentci/__init__.py +++ b/src/agentci/__init__.py @@ -1,4 +1,3 @@ -"""Gitea-triggered Codex workflow host.""" +"""Gitea-triggered OpenCode workflow host.""" __version__ = "0.1.0" - diff --git a/src/agentci/adapters/codex.py b/src/agentci/adapters/codex.py deleted file mode 100644 index ab53762..0000000 --- a/src/agentci/adapters/codex.py +++ /dev/null @@ -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) diff --git a/src/agentci/adapters/gitea.py b/src/agentci/adapters/gitea.py index adfe0a3..2e20e22 100644 --- a/src/agentci/adapters/gitea.py +++ b/src/agentci/adapters/gitea.py @@ -34,6 +34,13 @@ class GiteaClient: async def close(self) -> None: await self.client.aclose() + async def has_write_permission(self, owner: str, repo: str, username: str) -> bool: + response = await self._request( + "GET", f"/repos/{owner}/{repo}/collaborators/{username}/permission" + ) + permission = str(response.json().get("permission", "")).lower() + return permission in {"write", "admin", "owner"} + async def repository(self, owner: str, repo: str) -> RepositoryInfo: data = (await self._request("GET", f"/repos/{owner}/{repo}")).json() return RepositoryInfo( diff --git a/src/agentci/adapters/job_store.py b/src/agentci/adapters/job_store.py index 3600306..4d11873 100644 --- a/src/agentci/adapters/job_store.py +++ b/src/agentci/adapters/job_store.py @@ -39,8 +39,8 @@ class JobStore(Database): INSERT INTO jobs ( id, kind, target_key, repo_owner, repo_name, issue_number, pr_number, requester, message, comment_id, workflow_id, - status, stage, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + status, stage, runtime_session_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( job.id, @@ -56,6 +56,7 @@ class JobStore(Database): job.workflow_id, job.status, job.stage, + job.runtime_session_id, now(), ), ) @@ -91,6 +92,7 @@ class JobStore(Database): stage: str | None = None, error: str | None = None, workflow_id: str | None = None, + runtime_session_id: str | None = None, ) -> None: updates: dict[str, object] = {} if status is not None: @@ -103,6 +105,8 @@ class JobStore(Database): updates["error"] = error if workflow_id is not None: updates["workflow_id"] = workflow_id + if runtime_session_id is not None: + updates["runtime_session_id"] = runtime_session_id await self._update("jobs", job_id, updates) log.info( "job state updated", @@ -161,7 +165,7 @@ class JobStore(Database): ( JobStatus.FAILED, "interrupted", - "Service restarted during an active Codex turn", + "Service restarted during an active OpenCode turn", now(), JobStatus.RUNNING, ), @@ -192,4 +196,5 @@ def job_from_row( status=status or JobStatus(row["status"]), stage=stage or row["stage"], accepted_comment_id=row["accepted_comment_id"], + runtime_session_id=row["runtime_session_id"], ) diff --git a/src/agentci/adapters/opencode.py b/src/agentci/adapters/opencode.py new file mode 100644 index 0000000..fb037c5 --- /dev/null +++ b/src/agentci/adapters/opencode.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from time import monotonic +from typing import Any, TypeVar + +import httpx +from pydantic import BaseModel, ValidationError + +from agentci.adapters.codegraph import CodeGraphClient +from agentci.adapters.opencode_support import ( + api_contract_ready, + directory_headers, + elapsed_ms, + error_message, + load_schema, + model_parts, + models_ready, +) + +T = TypeVar("T", bound=BaseModel) +log = logging.getLogger(__name__) + + +class OpenCodeError(RuntimeError): + pass + + +class OpenCodeClient: + def __init__( + self, + *, + base_url: str, + username: str, + password: str, + schemas_dir: Path, + health_directory: Path, + required_models: tuple[tuple[str, str | None], ...], + timeout_seconds: int, + codegraph: CodeGraphClient | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.schemas_dir = schemas_dir + self.health_directory = health_directory + self.required_models = { + (*model_parts(model), variant) for model, variant in required_models + } + self._contract_valid: bool | None = None + self.timeout_seconds = timeout_seconds + self.codegraph = codegraph or CodeGraphClient() + self._active_sessions: dict[str, Path] = {} + self.client = httpx.AsyncClient( + base_url=base_url.rstrip("/"), + auth=httpx.BasicAuth(username, password), + timeout=httpx.Timeout(timeout_seconds, connect=10), + transport=transport, + ) + + async def close(self) -> None: + for session_id, workspace in tuple(self._active_sessions.items()): + await self.abort(session_id, workspace) + await self.client.aclose() + + async def ready(self) -> bool: + try: + health = await self.client.get("/global/health", timeout=10) + health.raise_for_status() + if health.json().get("healthy") is not True: + return False + if not str(health.json().get("version", "")).startswith("1."): + return False + if self._contract_valid is None: + document = await self.client.get("/doc", timeout=10) + document.raise_for_status() + self._contract_valid = api_contract_ready(document.json()) + if not self._contract_valid: + return False + providers = await self.client.get( + "/provider", headers=directory_headers(self.health_directory), timeout=10 + ) + providers.raise_for_status() + return models_ready(providers.json(), self.required_models) + except (httpx.HTTPError, TypeError, ValueError): + return False + + async def start( + self, + *, + workspace: Path, + prompt: str, + model: str, + variant: str | None, + schema_name: str, + result_type: type[T], + ) -> tuple[str, T]: + session_id = await self.create_session(workspace, schema_name) + result = await self.resume( + session_id=session_id, + workspace=workspace, + prompt=prompt, + model=model, + variant=variant, + schema_name=schema_name, + result_type=result_type, + ) + return session_id, result + + async def create_session(self, workspace: Path, title: str) -> str: + response = await self._request( + "POST", + "/session", + workspace=workspace, + json={"title": f"Agent CI: {title.removesuffix('.json')}"}, + ) + session_id = response.get("id") + if not isinstance(session_id, str) or not session_id: + raise OpenCodeError("OpenCode did not return a session ID") + return session_id + + async def resume( + self, + *, + session_id: str, + prompt: str, + model: str, + variant: str | None, + workspace: Path, + schema_name: str, + result_type: type[T], + ) -> T: + await self.codegraph.prepare(workspace) + self._active_sessions[session_id] = workspace + try: + return await self._prompt( + session_id=session_id, + workspace=workspace, + prompt=prompt, + model=model, + variant=variant, + schema_name=schema_name, + result_type=result_type, + ) + except (asyncio.CancelledError, OpenCodeError): + await asyncio.shield(self.abort(session_id, workspace)) + raise + finally: + self._active_sessions.pop(session_id, None) + + async def _prompt( + self, + *, + session_id: str, + workspace: Path, + prompt: str, + model: str, + variant: str | None, + schema_name: str, + result_type: type[T], + ) -> T: + try: + schema = load_schema(self.schemas_dir, schema_name) + except ValueError as exc: + raise OpenCodeError(str(exc)) from exc + provider_id, model_id = model_parts(model) + repair = "The previous response did not produce the required structured result. " + repair += "Return the requested result now without repeating repository work." + for attempt, message in enumerate((prompt, repair), start=1): + payload: dict[str, Any] = { + "model": {"providerID": provider_id, "modelID": model_id}, + "agent": "build", + "parts": [{"type": "text", "text": message}], + "format": {"type": "json_schema", "schema": schema, "retryCount": 0}, + } + if variant: + payload["variant"] = variant + started = monotonic() + extra = {"operation": "opencode.prompt", "attempt": attempt} + log.info("OpenCode turn started", extra=extra) + try: + response = await self._request( + "POST", + f"/session/{session_id}/message", + workspace=workspace, + json=payload, + ) + except httpx.TimeoutException as exc: + await self.abort(session_id, workspace) + message = f"OpenCode turn exceeded {self.timeout_seconds} seconds" + raise OpenCodeError(message) from exc + info = response.get("info") + if not isinstance(info, dict): + raise OpenCodeError("OpenCode response did not include assistant metadata") + error = error_message(info.get("error")) + structured = info.get("structured") + validation_failed = False + if structured is not None: + try: + result = result_type.model_validate(structured) + except ValidationError as exc: + error = f"structured result failed validation: {exc}" + validation_failed = True + else: + extra["duration_ms"] = elapsed_ms(started) + log.info("OpenCode turn completed", extra=extra) + return result + if attempt == 2 or ( + error and "StructuredOutput" not in error and not validation_failed + ): + raise OpenCodeError(f"OpenCode did not return a valid result: {error or 'missing'}") + log.warning("OpenCode structured result will be retried", extra=extra) + raise OpenCodeError("OpenCode did not return a valid result") + + async def _request( + self, + method: str, + path: str, + *, + workspace: Path, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = await self.client.request( + method, path, headers=directory_headers(workspace), json=json + ) + response.raise_for_status() + payload = response.json() + except httpx.TimeoutException: + raise + except (httpx.HTTPError, ValueError) as exc: + detail = "" + if isinstance(exc, httpx.HTTPStatusError): + detail = f": {exc.response.text[-2000:]}" + message = f"OpenCode request failed: {method} {path}{detail}" + raise OpenCodeError(message) from exc + if not isinstance(payload, dict): + raise OpenCodeError(f"OpenCode returned an invalid response for {method} {path}") + return payload + + async def abort(self, session_id: str, workspace: Path) -> None: + try: + await self.client.post( + f"/session/{session_id}/abort", + headers=directory_headers(workspace), + timeout=10, + ) + except httpx.HTTPError: + log.exception("OpenCode session could not be aborted") diff --git a/src/agentci/adapters/opencode_support.py b/src/agentci/adapters/opencode_support.py new file mode 100644 index 0000000..6e17904 --- /dev/null +++ b/src/agentci/adapters/opencode_support.py @@ -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 diff --git a/src/agentci/adapters/workflow_store.py b/src/agentci/adapters/workflow_store.py index ffcba87..3037fad 100644 --- a/src/agentci/adapters/workflow_store.py +++ b/src/agentci/adapters/workflow_store.py @@ -8,6 +8,15 @@ from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus class WorkflowStore(Database): + async def get_workflow(self, workflow_id: str) -> Workflow | None: + return await self._run( + lambda connection: workflow_from_row( + connection.execute( + "SELECT * FROM workflows WHERE id=?", (workflow_id,) + ).fetchone() + ) + ) + async def create_workflow(self, workflow: Workflow) -> None: timestamp = now() await self._run( @@ -16,9 +25,9 @@ class WorkflowStore(Database): INSERT INTO workflows ( id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch, workspace_path, primary_session_id, - reviewer_session_id, artifact, review_json, status, + reviewer_session_id, artifact, review_json, status, runtime, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( workflow.id, @@ -35,6 +44,7 @@ class WorkflowStore(Database): workflow.artifact, workflow.review_json, workflow.status, + workflow.runtime, timestamp, timestamp, ), @@ -135,6 +145,7 @@ def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None: issue_number=row["issue_number"], pr_number=row["pr_number"], base_sha=row["base_sha"], + runtime=row["runtime"], branch=row["branch"], workspace_path=Path(row["workspace_path"]), primary_session_id=row["primary_session_id"], diff --git a/src/agentci/api/health.py b/src/agentci/api/health.py index 837d8e9..796ae0d 100644 --- a/src/agentci/api/health.py +++ b/src/agentci/api/health.py @@ -12,8 +12,7 @@ async def live() -> dict[str, str]: @router.get("/health/ready") async def ready(request: Request, response: Response) -> dict[str, str]: - if not await request.app.state.container.codex.login_ready(): + if not await request.app.state.container.opencode.ready(): response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE - return {"status": "not-ready", "reason": "codex is not authenticated"} + return {"status": "not-ready", "reason": "opencode provider is not connected"} return {"status": "ready"} - diff --git a/src/agentci/api/webhook.py b/src/agentci/api/webhook.py index 0a5b8a3..8e3d762 100644 --- a/src/agentci/api/webhook.py +++ b/src/agentci/api/webhook.py @@ -70,6 +70,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response: if not event.body.strip().startswith("/agent"): return Response(status_code=status.HTTP_204_NO_CONTENT) log.info("agent command received", extra=extra) + permitted = await container.gitea.has_write_permission( + event.repo_owner, event.repo_name, event.requester + ) + if not permitted: + log.warning("agent command rejected: insufficient permission", extra=extra) + if await container.storage.record_delivery(event.delivery_id, event.comment_id): + await container.gitea.create_comment( + event.repo_owner, + event.repo_name, + event.issue_number, + "Agent command rejected: repository write permission is required.", + ) + return Response(status_code=status.HTTP_202_ACCEPTED) try: command = parse_command(event.body) except CommandError as exc: diff --git a/src/agentci/app.py b/src/agentci/app.py index ab3871f..fa2f292 100644 --- a/src/agentci/app.py +++ b/src/agentci/app.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -37,8 +37,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: finally: log.info("service shutdown started", extra={"operation": "service.shutdown"}) stop.set() + worker_task.cancel() try: - await worker_task + with suppress(asyncio.CancelledError): + await worker_task finally: await container.close() log.info("service shutdown completed", extra={"operation": "service.shutdown"}) diff --git a/src/agentci/config.py b/src/agentci/config.py index 7e3656b..141b267 100644 --- a/src/agentci/config.py +++ b/src/agentci/config.py @@ -5,7 +5,7 @@ from functools import cached_property from pathlib import Path from typing import Annotated -from pydantic import Field, SecretStr, field_validator +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @@ -21,38 +21,45 @@ class Settings(BaseSettings): host: str = "0.0.0.0" port: int = 8080 data_dir: Path = Path("/var/lib/agentci") - codex_home: Path = Path("/var/lib/codex") gitea_url: str = "http://gitea:3000" gitea_token_file: Path = Path("/run/secrets/gitea_token") webhook_secret_file: Path = Path("/run/secrets/webhook_secret") + opencode_url: str = "http://opencode:4096" + opencode_server_username: str = "opencode" + opencode_server_password_file: Path = Path("/run/secrets/opencode_server_password") bot_username: str = "agentci" bot_name: str = "Agent CI" bot_email: str = "agentci@localhost" branch_prefix: str = "agent" askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh") - plan_model: str = "gpt-5.6-sol" - plan_reasoning: str = "medium" - implement_model: str = "gpt-5.6-sol" - implement_reasoning: str = "high" - research_model: str = "gpt-5.6-luna" - research_reasoning: str = "high" - context7_api_key: SecretStr | None = None + plan_model: str = "openai/gpt-5.6-sol" + plan_variant: str | None = None + implement_model: str = "openai/gpt-5.6-sol" + implement_variant: str | None = None + research_model: str = "openai/gpt-5.6-luna" plan_review_rounds: int = Field(default=4, ge=1, le=20) implement_review_rounds: int = Field(default=3, ge=1, le=20) turn_timeout_seconds: int = Field(default=3600, ge=60) install_script_timeout_seconds: int = Field(default=900, ge=1) worker_poll_seconds: float = Field(default=1.0, ge=0.1) - public_agent_network: bool = True install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list) install_scripts_dir: Path = Path("/etc/agentci/install-scripts") python_version: str = "3.13" dotnet_channel: str = "10.0" - @field_validator("gitea_url") + @field_validator("gitea_url", "opencode_url") @classmethod def strip_url(cls, value: str) -> str: return value.rstrip("/") + @field_validator("plan_model", "implement_model", "research_model") + @classmethod + def validate_opencode_model(cls, value: str) -> str: + provider, separator, model = value.partition("/") + if not separator or not provider or not model: + raise ValueError("OpenCode models must use provider/model format") + return value + @field_validator("install_scripts", mode="before") @classmethod def parse_install_scripts(cls, value: object) -> list[str]: @@ -77,6 +84,12 @@ class Settings(BaseSettings): def webhook_secret(self) -> bytes: return self._read_secret(self.webhook_secret_file, "webhook secret").encode() + @cached_property + def opencode_server_password(self) -> str: + return self._read_secret( + self.opencode_server_password_file, "OpenCode server password" + ) + @property def database_path(self) -> Path: return self.data_dir / "agentci.sqlite3" diff --git a/src/agentci/container.py b/src/agentci/container.py index f42ac05..30555c3 100644 --- a/src/agentci/container.py +++ b/src/agentci/container.py @@ -4,10 +4,10 @@ import logging from dataclasses import dataclass from pathlib import Path -from agentci.adapters.codex import CodexClient from agentci.adapters.development import DevelopmentEnvironment from agentci.adapters.git import GitClient from agentci.adapters.gitea import GiteaClient +from agentci.adapters.opencode import OpenCodeClient from agentci.adapters.storage import Storage from agentci.config import Settings from agentci.prompts import PromptLibrary @@ -24,11 +24,12 @@ class Container: storage: Storage gitea: GiteaClient git: GitClient - codex: CodexClient + opencode: OpenCodeClient worker: Worker async def close(self) -> None: log.info("container shutdown started", extra={"operation": "container.close"}) + await self.opencode.close() await self.gitea.close() log.info("container shutdown completed", extra={"operation": "container.close"}) @@ -38,7 +39,6 @@ async def build_container(settings: Settings) -> Container: package_dir = Path(__file__).parent settings.data_dir.mkdir(parents=True, exist_ok=True) settings.workspaces_dir.mkdir(parents=True, exist_ok=True) - settings.codex_home.mkdir(parents=True, exist_ok=True) storage = Storage(settings.database_path, package_dir / "migrations") await storage.initialize() gitea = GiteaClient(settings.gitea_url, settings.gitea_token) @@ -50,18 +50,18 @@ async def build_container(settings: Settings) -> Container: commit_name=settings.bot_name, commit_email=settings.bot_email, ) - codex = CodexClient( - codex_home=settings.codex_home, + opencode = OpenCodeClient( + base_url=settings.opencode_url, + username=settings.opencode_server_username, + password=settings.opencode_server_password, schemas_dir=package_dir / "prompts" / "schemas", - timeout_seconds=settings.turn_timeout_seconds, - research_model=settings.research_model, - research_reasoning=settings.research_reasoning, - context7_api_key=( - settings.context7_api_key.get_secret_value() - if settings.context7_api_key is not None - else None + health_directory=settings.workspaces_dir, + required_models=( + (settings.plan_model, settings.plan_variant), + (settings.implement_model, settings.implement_variant), + (settings.research_model, None), ), - tools_bin=settings.dev_tools_dir / "bin", + timeout_seconds=settings.turn_timeout_seconds, ) prompts = PromptLibrary() context = ContextBuilder(gitea, storage) @@ -78,7 +78,7 @@ async def build_container(settings: Settings) -> Container: storage=storage, gitea=gitea, git=git, - codex=codex, + opencode=opencode, prompts=prompts, context=context, development=development, @@ -87,10 +87,11 @@ async def build_container(settings: Settings) -> Container: worker = Worker( storage=storage, gitea=gitea, - codex=codex, + opencode=opencode, dispatcher=dispatcher, poll_seconds=settings.worker_poll_seconds, + workspaces_dir=settings.workspaces_dir, ) - container = Container(settings, storage, gitea, git, codex, worker) + container = Container(settings, storage, gitea, git, opencode, worker) log.info("container initialization completed", extra={"operation": "container.build"}) return container diff --git a/src/agentci/domain/models.py b/src/agentci/domain/models.py index ad2abdc..a2a495d 100644 --- a/src/agentci/domain/models.py +++ b/src/agentci/domain/models.py @@ -125,6 +125,7 @@ class Job: status: JobStatus = JobStatus.QUEUED stage: str = "queued" accepted_comment_id: int | None = None + runtime_session_id: str | None = None @dataclass @@ -136,6 +137,7 @@ class Workflow: issue_number: int workspace_path: Path base_sha: str + runtime: str = "opencode" branch: str | None = None pr_number: int | None = None primary_session_id: str | None = None diff --git a/src/agentci/migrations/002_opencode_sessions.sql b/src/agentci/migrations/002_opencode_sessions.sql new file mode 100644 index 0000000..072d99f --- /dev/null +++ b/src/agentci/migrations/002_opencode_sessions.sql @@ -0,0 +1,2 @@ +ALTER TABLE workflows ADD COLUMN runtime TEXT NOT NULL DEFAULT 'codex'; +ALTER TABLE jobs ADD COLUMN runtime_session_id TEXT; diff --git a/src/agentci/worker.py b/src/agentci/worker.py index 1c68343..898cfee 100644 --- a/src/agentci/worker.py +++ b/src/agentci/worker.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio import logging from contextlib import suppress +from pathlib import Path -from agentci.adapters.codex import CodexClient from agentci.adapters.gitea import GiteaClient +from agentci.adapters.opencode import OpenCodeClient from agentci.adapters.storage import Storage from agentci.domain.models import Job, JobStatus from agentci.workflows.common import JobRejected @@ -20,24 +21,26 @@ class Worker: *, storage: Storage, gitea: GiteaClient, - codex: CodexClient, + opencode: OpenCodeClient, dispatcher: Dispatcher, poll_seconds: float, + workspaces_dir: Path, ) -> None: self.storage = storage self.gitea = gitea - self.codex = codex + self.opencode = opencode self.dispatcher = dispatcher self.poll_seconds = poll_seconds + self.workspaces_dir = workspaces_dir async def run(self, stop: asyncio.Event) -> None: log.info("worker started", extra={"operation": "worker.run"}) await self._report_interrupted() try: while not stop.is_set(): - if not await self.codex.login_ready(): + if not await self.opencode.ready(): log.warning( - "worker waiting for Codex authentication", + "worker waiting for OpenCode provider authentication", extra={"operation": "worker.poll"}, ) await self._wait(stop) @@ -106,12 +109,33 @@ class Worker: extra={"operation": "worker.recover", "item_count": len(jobs)}, ) for job in jobs: + await self._abort_job_sessions(job) await self._safe_fail_workflow(job) await self._safe_comment( job, f"Agent job `{job.id}` failed because the service restarted during execution.", ) + async def _abort_job_sessions(self, job: Job) -> None: + sessions: set[tuple[str, Path]] = set() + workflow = None + if job.workflow_id: + workflow = await self.storage.get_workflow(job.workflow_id) + if workflow is not None: + sessions.update( + (session_id, workflow.workspace_path) + for session_id in ( + workflow.primary_session_id, + workflow.reviewer_session_id, + ) + if session_id + ) + elif job.runtime_session_id: + workspace = self.workspaces_dir / f"fix-{job.id}" / "repo" + sessions.add((job.runtime_session_id, workspace)) + for session_id, workspace in sessions: + await self.opencode.abort(session_id, workspace) + async def _safe_comment(self, job: Job, body: str) -> None: try: await self.gitea.create_comment( diff --git a/src/agentci/workflows/__init__.py b/src/agentci/workflows/__init__.py index d6948ab..9416311 100644 --- a/src/agentci/workflows/__init__.py +++ b/src/agentci/workflows/__init__.py @@ -1,2 +1 @@ -"""Codex workflow orchestration.""" - +"""OpenCode workflow orchestration.""" diff --git a/src/agentci/workflows/change_set.py b/src/agentci/workflows/change_set.py index 172be46..3d033f4 100644 --- a/src/agentci/workflows/change_set.py +++ b/src/agentci/workflows/change_set.py @@ -22,7 +22,7 @@ class ChangeSet: ) -> str: await self.deps.storage.update_job(job.id, stage="validating changes") if not await self.deps.git.has_changes(workspace): - raise JobRejected("Codex completed without producing any file changes.") + raise JobRejected("OpenCode completed without producing any file changes.") await self.deps.git.diff_check(workspace) title = _commit_title(result.summary_markdown) await self.deps.storage.update_job(job.id, stage="committing changes") @@ -54,4 +54,3 @@ def _commit_title(markdown: str) -> str: if value: return value[:72] return "apply requested changes" - diff --git a/src/agentci/workflows/code_review.py b/src/agentci/workflows/code_review.py index fcfb0a8..659ac96 100644 --- a/src/agentci/workflows/code_review.py +++ b/src/agentci/workflows/code_review.py @@ -1,7 +1,12 @@ from __future__ import annotations from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow -from agentci.workflows.common import Dependencies, report_for_prompt, report_json +from agentci.workflows.common import ( + Dependencies, + report_for_prompt, + report_json, + required_session, +) class CodeReviewLoop: @@ -44,12 +49,11 @@ class CodeReviewLoop: review=report_for_prompt(workflow.review_json), development_environment=self.deps.development.description, ) - result = await self.deps.codex.resume( - session_id=_required(workflow.primary_session_id), + result = await self.deps.opencode.resume( + session_id=required_session(workflow.primary_session_id), prompt=prompt, model=self.deps.settings.implement_model, - reasoning=self.deps.settings.implement_reasoning, - permission="agentci-write", + variant=self.deps.settings.implement_variant, workspace=workflow.workspace_path, schema_name="agent_result.json", result_type=AgentResult, @@ -71,30 +75,27 @@ class CodeReviewLoop: pull_context=pull_context, ) if workflow.reviewer_session_id: - return await self.deps.codex.resume( + return await self.deps.opencode.resume( session_id=workflow.reviewer_session_id, prompt=prompt, model=self.deps.settings.implement_model, - reasoning=self.deps.settings.implement_reasoning, - permission="agentci-review", + variant=self.deps.settings.implement_variant, workspace=workflow.workspace_path, schema_name="review.json", result_type=ReviewReport, ) - session_id, report = await self.deps.codex.start( + session_id = await self.deps.opencode.create_session( + workflow.workspace_path, "implementation-review" + ) + workflow.reviewer_session_id = session_id + await self.deps.storage.update_workflow(workflow) + report = await self.deps.opencode.resume( + session_id=session_id, workspace=workflow.workspace_path, prompt=prompt, model=self.deps.settings.implement_model, - reasoning=self.deps.settings.implement_reasoning, - permission="agentci-review", + variant=self.deps.settings.implement_variant, schema_name="review.json", result_type=ReviewReport, ) - workflow.reviewer_session_id = session_id return report - - -def _required(value: str | None) -> str: - if value is None: - raise RuntimeError("Expected a persisted Codex session ID") - return value diff --git a/src/agentci/workflows/common.py b/src/agentci/workflows/common.py index ebc9158..1d543a3 100644 --- a/src/agentci/workflows/common.py +++ b/src/agentci/workflows/common.py @@ -3,10 +3,10 @@ from __future__ import annotations import json from dataclasses import dataclass -from agentci.adapters.codex import CodexClient from agentci.adapters.development import DevelopmentEnvironment from agentci.adapters.git import GitClient from agentci.adapters.gitea import GiteaClient +from agentci.adapters.opencode import OpenCodeClient from agentci.adapters.storage import Storage from agentci.config import Settings from agentci.domain.models import ReviewReport @@ -18,13 +18,19 @@ class JobRejected(RuntimeError): """A safe, expected workflow rejection to publish to the requester.""" +def required_session(value: str | None) -> str: + if value is None: + raise RuntimeError("Expected a persisted OpenCode session ID") + return value + + @dataclass(frozen=True) class Dependencies: settings: Settings storage: Storage gitea: GiteaClient git: GitClient - codex: CodexClient + opencode: OpenCodeClient prompts: PromptLibrary context: ContextBuilder development: DevelopmentEnvironment diff --git a/src/agentci/workflows/implement.py b/src/agentci/workflows/implement.py index 55ecc95..ff5943a 100644 --- a/src/agentci/workflows/implement.py +++ b/src/agentci/workflows/implement.py @@ -76,16 +76,19 @@ class ImplementWorkflow: request=job.message or "(no additional request)", development_environment=self.deps.development.description, ) - session_id, result = await self.deps.codex.start( + session_id = await self.deps.opencode.create_session(workspace, "implementation") + workflow.primary_session_id = session_id + await self.deps.storage.update_workflow(workflow) + await self.deps.storage.update_job(job.id, runtime_session_id=session_id) + result = await self.deps.opencode.resume( + session_id=session_id, workspace=workspace, prompt=prompt, model=self.deps.settings.implement_model, - reasoning=self.deps.settings.implement_reasoning, - permission="agentci-write", + variant=self.deps.settings.implement_variant, schema_name="agent_result.json", result_type=AgentResult, ) - workflow.primary_session_id = session_id workflow.artifact = result.model_dump_json() await self.deps.storage.update_workflow(workflow) result, report = await self.review.run( diff --git a/src/agentci/workflows/plan.py b/src/agentci/workflows/plan.py index 7af9c8b..d13cd50 100644 --- a/src/agentci/workflows/plan.py +++ b/src/agentci/workflows/plan.py @@ -17,6 +17,7 @@ from agentci.workflows.common import ( agent_comment, report_for_prompt, report_json, + required_session, review_markdown, ) @@ -56,16 +57,19 @@ class PlanWorkflow: context=context, request=job.message or "(no additional request)", ) - session_id, artifact = await self.deps.codex.start( + session_id = await self.deps.opencode.create_session(workspace, "plan") + workflow.primary_session_id = session_id + await self.deps.storage.update_workflow(workflow) + await self.deps.storage.update_job(job.id, runtime_session_id=session_id) + artifact = await self.deps.opencode.resume( + session_id=session_id, workspace=workspace, prompt=prompt, model=self.deps.settings.plan_model, - reasoning=self.deps.settings.plan_reasoning, - permission="agentci-read", + variant=self.deps.settings.plan_variant, schema_name="plan.json", result_type=PlanArtifact, ) - workflow.primary_session_id = session_id workflow.artifact = artifact.plan_markdown await self.deps.storage.update_workflow(workflow) report = await self._review_loop(job, workflow, context, artifact) @@ -73,18 +77,22 @@ class PlanWorkflow: async def discuss(self, job: Job) -> None: workflow = await self._latest_plan(job) + if workflow.runtime != "opencode": + raise JobRejected( + "The latest plan predates OpenCode and cannot be resumed; " + "start a new `/agent plan`." + ) if not workflow.primary_session_id or not workflow.artifact: raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.") await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing") prompt = self.deps.prompts.render( "discuss", artifact=workflow.artifact, message=job.message ) - reply = await self.deps.codex.resume( + reply = await self.deps.opencode.resume( session_id=workflow.primary_session_id, prompt=prompt, model=self.deps.settings.plan_model, - reasoning=self.deps.settings.plan_reasoning, - permission="agentci-read", + variant=self.deps.settings.plan_variant, workspace=workflow.workspace_path, schema_name="discussion.json", result_type=DiscussionReply, @@ -99,6 +107,8 @@ class PlanWorkflow: async def iterate(self, job: Job) -> None: await self._reject_if_active_or_merged_pr(job) workflow = await self._latest_plan(job) + if workflow.runtime != "opencode": + raise JobRejected("The latest plan predates OpenCode; start a new plan.") if not workflow.primary_session_id or not workflow.reviewer_session_id: raise JobRejected("The latest plan is missing resumable sessions; start a new plan.") if not workflow.artifact: @@ -116,12 +126,11 @@ class PlanWorkflow: review=report_for_prompt(workflow.review_json), message=job.message or "(refine using the latest discussion and prior review)", ) - artifact = await self.deps.codex.resume( + artifact = await self.deps.opencode.resume( session_id=workflow.primary_session_id, prompt=prompt, model=self.deps.settings.plan_model, - reasoning=self.deps.settings.plan_reasoning, - permission="agentci-read", + variant=self.deps.settings.plan_variant, workspace=workflow.workspace_path, schema_name="plan.json", result_type=PlanArtifact, @@ -152,12 +161,11 @@ class PlanWorkflow: artifact=artifact.plan_markdown, review=report_for_prompt(workflow.review_json), ) - artifact = await self.deps.codex.resume( - session_id=_required(workflow.primary_session_id), + artifact = await self.deps.opencode.resume( + session_id=required_session(workflow.primary_session_id), prompt=prompt, model=self.deps.settings.plan_model, - reasoning=self.deps.settings.plan_reasoning, - permission="agentci-read", + variant=self.deps.settings.plan_variant, workspace=workflow.workspace_path, schema_name="plan.json", result_type=PlanArtifact, @@ -171,26 +179,27 @@ class PlanWorkflow: "plan_review", context=context, artifact=artifact.plan_markdown ) if workflow.reviewer_session_id: - return await self.deps.codex.resume( + return await self.deps.opencode.resume( session_id=workflow.reviewer_session_id, prompt=prompt, model=self.deps.settings.plan_model, - reasoning=self.deps.settings.plan_reasoning, - permission="agentci-review", + variant=self.deps.settings.plan_variant, workspace=workflow.workspace_path, schema_name="review.json", result_type=ReviewReport, ) - session_id, report = await self.deps.codex.start( + session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review") + workflow.reviewer_session_id = session_id + await self.deps.storage.update_workflow(workflow) + report = await self.deps.opencode.resume( + session_id=session_id, workspace=workflow.workspace_path, prompt=prompt, model=self.deps.settings.plan_model, - reasoning=self.deps.settings.plan_reasoning, - permission="agentci-review", + variant=self.deps.settings.plan_variant, schema_name="review.json", result_type=ReviewReport, ) - workflow.reviewer_session_id = session_id return report async def _finish( @@ -239,9 +248,3 @@ class PlanWorkflow: f"Issue plan iteration is disabled because agent PR #{pull.number} " "is open or merged. Iterate an open implementation on its PR." ) - - -def _required(value: str | None) -> str: - if value is None: # Defensive: workflows with missing sessions are rejected earlier. - raise RuntimeError("Expected a persisted Codex session ID") - return value diff --git a/src/agentci/workflows/pull_request.py b/src/agentci/workflows/pull_request.py index ae12484..f0cb1e6 100644 --- a/src/agentci/workflows/pull_request.py +++ b/src/agentci/workflows/pull_request.py @@ -28,6 +28,8 @@ class PullRequestWorkflow: raise JobRejected( "This is not an open agent-created implementation PR. Use `/agent fix`." ) + if workflow.runtime != "opencode": + raise JobRejected("The implementation predates OpenCode and cannot be resumed.") if not workflow.primary_session_id or not workflow.reviewer_session_id: raise JobRejected("The implementation sessions cannot be resumed.") pull, context = await self.deps.context.pull_request_context( @@ -54,12 +56,11 @@ class PullRequestWorkflow: message=job.message or "(perform one additional reviewed refinement)", development_environment=self.deps.development.description, ) - result = await self.deps.codex.resume( + result = await self.deps.opencode.resume( session_id=workflow.primary_session_id, prompt=prompt, model=self.deps.settings.implement_model, - reasoning=self.deps.settings.implement_reasoning, - permission="agentci-write", + variant=self.deps.settings.implement_variant, workspace=workflow.workspace_path, schema_name="agent_result.json", result_type=AgentResult, @@ -125,12 +126,14 @@ class PullRequestWorkflow: development_environment=self.deps.development.description, ) await self.deps.storage.update_job(job.id, stage="fixing") - _, result = await self.deps.codex.start( + session_id = await self.deps.opencode.create_session(workspace, "fix") + await self.deps.storage.update_job(job.id, runtime_session_id=session_id) + result = await self.deps.opencode.resume( + session_id=session_id, workspace=workspace, prompt=prompt, model=self.deps.settings.implement_model, - reasoning=self.deps.settings.implement_reasoning, - permission="agentci-write", + variant=self.deps.settings.implement_variant, schema_name="agent_result.json", result_type=AgentResult, ) diff --git a/tests/test_code_review.py b/tests/test_code_review.py index ee0dc06..019e3f0 100644 --- a/tests/test_code_review.py +++ b/tests/test_code_review.py @@ -28,15 +28,14 @@ def serious_report() -> ReviewReport: ) -class FakeCodex: +class FakeOpenCode: def __init__(self, reports: list[ReviewReport]) -> None: self.reports = iter(reports) self.reviews = 0 self.revisions = 0 - async def start(self, **_kwargs): - self.reviews += 1 - return "reviewer", next(self.reports) + async def create_session(self, *_args): + return "reviewer" async def resume(self, **kwargs): if kwargs["result_type"] is ReviewReport: @@ -60,15 +59,15 @@ class FakePrompts: def objects(rounds: int, reports: list[ReviewReport]): - codex = FakeCodex(reports) + opencode = FakeOpenCode(reports) settings = SimpleNamespace( implement_review_rounds=rounds, implement_model="model", - implement_reasoning="high", + implement_variant="high", ) deps = SimpleNamespace( settings=settings, - codex=codex, + opencode=opencode, storage=FakeStorage(), prompts=FakePrompts(), development=SimpleNamespace(description="python"), @@ -95,12 +94,12 @@ def objects(rounds: int, reports: list[ReviewReport]): message="", comment_id=1, ) - return CodeReviewLoop(deps), codex, workflow, job # type: ignore[arg-type] + return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type] async def test_stops_after_clean_second_review() -> None: clean = ReviewReport(summary="Ready", findings=[]) - loop, codex, workflow, job = objects(4, [serious_report(), clean]) + loop, opencode, workflow, job = objects(4, [serious_report(), clean]) _, report = await loop.run( job, workflow, @@ -109,12 +108,12 @@ async def test_stops_after_clean_second_review() -> None: AgentResult(summary_markdown="initial", tests=[]), ) assert not report.has_serious_findings - assert codex.reviews == 2 - assert codex.revisions == 1 + assert opencode.reviews == 2 + assert opencode.revisions == 1 async def test_does_not_make_unreviewed_final_revision() -> None: - loop, codex, workflow, job = objects( + loop, opencode, workflow, job = objects( 3, [serious_report(), serious_report(), serious_report()] ) _, report = await loop.run( @@ -125,5 +124,5 @@ async def test_does_not_make_unreviewed_final_revision() -> None: AgentResult(summary_markdown="initial", tests=[]), ) assert report.has_serious_findings - assert codex.reviews == 3 - assert codex.revisions == 2 + assert opencode.reviews == 3 + assert opencode.revisions == 2 diff --git a/tests/test_codex.py b/tests/test_codex.py deleted file mode 100644 index 82b241e..0000000 --- a/tests/test_codex.py +++ /dev/null @@ -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" diff --git a/tests/test_config.py b/tests/test_config.py index 725d34e..78d84d4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,3 +33,9 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> settings = Settings(_env_file=None) # type: ignore[call-arg] assert settings.install_scripts == ["python", "dotnet"] + + +@pytest.mark.parametrize("field", ["plan_model", "implement_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] diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index e7c53be..b1be54f 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -1,12 +1,12 @@ from pathlib import Path -def test_dotnet_wrapper_uses_sandbox_writable_runtime_directories() -> None: +def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None: root = Path(__file__).parents[1] script = (root / "install-scripts" / "dotnet").read_text() - assert 'DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' in script - assert 'NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' in script - http_cache = 'NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' - assert http_cache in script + assert "export DOTNET_ROOT=" in script + assert "/tmp/agentci-dotnet" not in script + assert "DEV_TOOLS_DIR/runtime/dotnet" in script + assert "NUGET_PACKAGES" in script assert 'export HOME="$DOTNET_CLI_HOME"' in script diff --git a/tests/test_opencode.py b/tests/test_opencode.py new file mode 100644 index 0000000..3df054c --- /dev/null +++ b/tests/test_opencode.py @@ -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() diff --git a/tests/test_opencode_deployment.py b/tests/test_opencode_deployment.py new file mode 100644 index 0000000..ca10e62 --- /dev/null +++ b/tests/test_opencode_deployment.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path + + +def test_config_grants_all_agents_unrestricted_permissions() -> None: + root = Path(__file__).parents[1] + config = json.loads((root / "opencode" / "opencode.json").read_text()) + + assert config["permission"] == "allow" + assert all( + config["agent"][name]["permission"] == "allow" + for name in ("build", "plan", "general", "explore", "research") + ) + assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"] + assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp" + + +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: true" 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 "ARG OPENCODE_REFRESH" in dockerfile + assert "OPENCODE_REFRESH is required" in dockerfile + assert "'opencode-ai@^1'" in dockerfile diff --git a/tests/test_opencode_support.py b/tests/test_opencode_support.py new file mode 100644 index 0000000..759cebe --- /dev/null +++ b/tests/test_opencode_support.py @@ -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")}) diff --git a/tests/test_storage.py b/tests/test_storage.py index 8f96538..8b61c62 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,3 +1,4 @@ +import sqlite3 from pathlib import Path import pytest @@ -101,3 +102,50 @@ async def test_failed_followup_does_not_invalidate_completed_workflow( loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) assert loaded is not None assert loaded.status is WorkflowStatus.COMPLETED + + +async def test_opencode_migration_preserves_and_tags_legacy_session_ids(tmp_path: Path) -> None: + legacy_migrations = tmp_path / "legacy-migrations" + legacy_migrations.mkdir() + migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations" + (legacy_migrations / "001_initial.sql").write_text( + (migrations / "001_initial.sql").read_text() + ) + database = tmp_path / "legacy.sqlite3" + legacy = Storage(database, legacy_migrations) + await legacy.initialize() + with sqlite3.connect(database) as connection: + connection.execute( + """ + INSERT INTO workflows ( + id, kind, repo_owner, repo_name, issue_number, base_sha, + workspace_path, primary_session_id, reviewer_session_id, + artifact, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "legacy-workflow", + "plan", + "alice", + "repo", + 3, + "abc", + str(tmp_path / "repo"), + "legacy-primary", + "legacy-reviewer", + "# Preserved plan", + "completed", + "2026-07-20T00:00:00+00:00", + "2026-07-20T00:00:00+00:00", + ), + ) + + migrated = Storage(database, migrations) + await migrated.initialize() + loaded = await migrated.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) + + assert loaded is not None + assert loaded.artifact == "# Preserved plan" + assert loaded.primary_session_id == "legacy-primary" + assert loaded.reviewer_session_id == "legacy-reviewer" + assert loaded.runtime == "codex" diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 70448ef..4042c69 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -29,9 +29,13 @@ class FakeStorage: class FakeGitea: - def __init__(self) -> None: + def __init__(self, permitted: bool = True) -> None: + self.permitted = permitted self.comments: list[str] = [] + async def has_write_permission(self, *_args): + return self.permitted + async def create_comment(self, _owner, _repo, _number, body): self.comments.append(body) return len(self.comments) @@ -72,6 +76,21 @@ async def test_authorized_command_is_queued() -> None: assert "queued" in gitea.comments[0] +async def test_unauthorized_command_is_rejected_and_deduplicated() -> None: + storage = FakeStorage() + gitea = FakeGitea(permitted=False) + container = SimpleNamespace(storage=storage, gitea=gitea) + event = _event_from_payload("delivery", payload("/agent implement")) + assert event is not None + + await _handle_command(container, event) + await _handle_command(container, event) + + assert storage.jobs == [] + assert len(gitea.comments) == 1 + assert "write permission" in gitea.comments[0] + + async def test_iterate_message_is_preserved_on_queued_job() -> None: storage = FakeStorage() container = SimpleNamespace(storage=storage, gitea=FakeGitea()) diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 0000000..fc172d0 --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,80 @@ +from pathlib import Path + +from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind +from agentci.worker import Worker + + +class FakeStorage: + def __init__(self, workflow: Workflow | None) -> None: + self.workflow = workflow + + async def get_workflow(self, _workflow_id: str) -> Workflow | None: + return self.workflow + + +class FakeOpenCode: + def __init__(self) -> None: + self.aborted: set[tuple[str, Path]] = set() + + async def abort(self, session_id: str, workspace: Path) -> None: + self.aborted.add((session_id, workspace)) + + +def job(*, workflow_id: str | None, runtime_session_id: str | None = None) -> Job: + return Job( + id="job", + kind=JobKind.FIX, + target_key="org/repo:pr:1", + repo_owner="org", + repo_name="repo", + issue_number=1, + pr_number=1, + requester="alice", + message="", + comment_id=1, + workflow_id=workflow_id, + runtime_session_id=runtime_session_id, + ) + + +def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker: + return Worker( + storage=storage, # type: ignore[arg-type] + gitea=None, # type: ignore[arg-type] + opencode=opencode, # type: ignore[arg-type] + dispatcher=None, # type: ignore[arg-type] + poll_seconds=1, + workspaces_dir=tmp_path, + ) + + +async def test_recovery_aborts_all_workflow_sessions(tmp_path: Path) -> None: + workspace = tmp_path / "workflow" / "repo" + workflow = Workflow( + id="flow", + kind=WorkflowKind.IMPLEMENT, + repo_owner="org", + repo_name="repo", + issue_number=1, + workspace_path=workspace, + base_sha="base", + primary_session_id="primary", + reviewer_session_id="reviewer", + ) + opencode = FakeOpenCode() + + await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions( + job(workflow_id=workflow.id) + ) + + assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)} + + +async def test_recovery_aborts_one_shot_fix_session(tmp_path: Path) -> None: + opencode = FakeOpenCode() + + await worker(tmp_path, FakeStorage(None), opencode)._abort_job_sessions( + job(workflow_id=None, runtime_session_id="fix-session") + ) + + assert opencode.aborted == {("fix-session", tmp_path / "fix-job" / "repo")}