Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378e372a4b | ||
|
|
73045258fa | ||
|
|
a9289e656c | ||
|
|
7a1b18f931 | ||
|
|
6b857e9adb | ||
|
|
45858bff06 | ||
|
|
d3946b195e | ||
|
|
b47430c963 | ||
|
|
6f24df8cd3 |
+10
-7
@@ -3,12 +3,16 @@ AGENTCI_GITEA_URL=http://gitea:3000
|
|||||||
AGENTCI_BOT_USERNAME=agentci
|
AGENTCI_BOT_USERNAME=agentci
|
||||||
AGENTCI_BOT_NAME=Agent CI
|
AGENTCI_BOT_NAME=Agent CI
|
||||||
AGENTCI_BOT_EMAIL=agentci@localhost
|
AGENTCI_BOT_EMAIL=agentci@localhost
|
||||||
AGENTCI_PLAN_MODEL=gpt-5.6-sol
|
OPENCODE_SERVER_USERNAME=opencode
|
||||||
AGENTCI_PLAN_REASONING=medium
|
AGENTCI_OPENCODE_VERSION=^1
|
||||||
AGENTCI_IMPLEMENT_MODEL=gpt-5.6-sol
|
AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol
|
||||||
AGENTCI_IMPLEMENT_REASONING=high
|
AGENTCI_PLAN_VARIANT=
|
||||||
AGENTCI_RESEARCH_MODEL=gpt-5.6-luna
|
AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol
|
||||||
AGENTCI_RESEARCH_REASONING=high
|
AGENTCI_IMPLEMENT_VARIANT=
|
||||||
|
AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna
|
||||||
|
AGENTCI_EXPLORE_VARIANT=low
|
||||||
|
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna
|
||||||
|
AGENTCI_RESEARCH_VARIANT=high
|
||||||
# Optional; Context7 works without a key at lower rate limits.
|
# Optional; Context7 works without a key at lower rate limits.
|
||||||
AGENTCI_CONTEXT7_API_KEY=
|
AGENTCI_CONTEXT7_API_KEY=
|
||||||
AGENTCI_PLAN_REVIEW_ROUNDS=4
|
AGENTCI_PLAN_REVIEW_ROUNDS=4
|
||||||
@@ -19,5 +23,4 @@ AGENTCI_INSTALL_SCRIPTS=
|
|||||||
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
|
AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900
|
||||||
AGENTCI_PYTHON_VERSION=3.13
|
AGENTCI_PYTHON_VERSION=3.13
|
||||||
AGENTCI_DOTNET_CHANNEL=10.0
|
AGENTCI_DOTNET_CHANNEL=10.0
|
||||||
CODEX_VERSION=0.144.6
|
|
||||||
CODEGRAPH_VERSION=1.3.1
|
CODEGRAPH_VERSION=1.3.1
|
||||||
|
|||||||
+33
-22
@@ -1,12 +1,14 @@
|
|||||||
ARG TEA_VERSION=0.14.2
|
ARG TEA_VERSION=0.14.2
|
||||||
|
|
||||||
FROM node:24-bookworm-slim AS codex
|
FROM node:24-bookworm-slim AS agent-tools
|
||||||
|
|
||||||
ARG CODEX_VERSION=0.144.6
|
|
||||||
ARG CODEGRAPH_VERSION=1.3.1
|
ARG CODEGRAPH_VERSION=1.3.1
|
||||||
|
ARG AGENTCI_OPENCODE_VERSION=^1
|
||||||
RUN npm install --global \
|
RUN npm install --global \
|
||||||
"@openai/codex@${CODEX_VERSION}" \
|
"opencode-ai@${AGENTCI_OPENCODE_VERSION}" \
|
||||||
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}"
|
"@colbymchenry/codegraph@${CODEGRAPH_VERSION}" \
|
||||||
|
&& version="$(opencode --version)" \
|
||||||
|
&& case "$version" in 1.*) ;; *) echo "Expected OpenCode 1.x, got $version" >&2; exit 1;; esac
|
||||||
|
|
||||||
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
|
FROM ghcr.io/astral-sh/uv:0.8.14 AS uv
|
||||||
|
|
||||||
@@ -19,30 +21,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
UV_LINK_MODE=copy \
|
UV_LINK_MODE=copy \
|
||||||
UV_NO_DEV=1 \
|
UV_NO_DEV=1 \
|
||||||
CODEGRAPH_TELEMETRY=0 \
|
CODEGRAPH_TELEMETRY=0 \
|
||||||
CODEX_HOME=/var/lib/codex \
|
|
||||||
XDG_CONFIG_HOME=/run/agentci \
|
XDG_CONFIG_HOME=/run/agentci \
|
||||||
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --yes --no-install-recommends \
|
&& apt-get install --yes --no-install-recommends \
|
||||||
adduser bubblewrap ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
|
adduser ca-certificates curl git libgcc-s1 libgssapi-krb5-2 \
|
||||||
libicu72 libssl3 libstdc++6 zlib1g \
|
libicu72 libssl3 libstdc++6 zlib1g \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
|
&& /usr/sbin/adduser --disabled-password --gecos "" --uid 10001 agentci \
|
||||||
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/codex /etc/codex /run/agentci \
|
&& mkdir -p /opt/agentci /var/lib/agentci /var/lib/opencode \
|
||||||
&& chown agentci:agentci /run/agentci \
|
/etc/opencode/home/.opencode /etc/opencode/xdg/opencode /run/agentci \
|
||||||
&& chmod u+s /usr/bin/bwrap
|
&& chmod 0555 /etc/opencode/home /etc/opencode/home/.opencode \
|
||||||
|
/etc/opencode/xdg /etc/opencode/xdg/opencode \
|
||||||
|
&& chown agentci:agentci /run/agentci
|
||||||
|
|
||||||
COPY --from=uv /uv /uvx /usr/local/bin/
|
COPY --from=uv /uv /uvx /usr/local/bin/
|
||||||
COPY --from=tea /bin/tea /usr/local/bin/tea
|
COPY --from=tea /bin/tea /usr/local/libexec/tea
|
||||||
COPY --from=codex /usr/local/bin/node /usr/local/bin/node
|
COPY --from=agent-tools /usr/local/bin/node /usr/local/bin/node
|
||||||
COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex
|
COPY --from=agent-tools /usr/local/lib/node_modules/opencode-ai/bin/opencode.exe /usr/local/bin/opencode
|
||||||
COPY --from=codex /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
|
COPY --from=agent-tools /usr/local/lib/node_modules/@colbymchenry /usr/local/lib/node_modules/@colbymchenry
|
||||||
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex \
|
RUN ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
|
||||||
&& ln -s \
|
|
||||||
/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex \
|
|
||||||
/usr/local/bin/codex-linux-sandbox \
|
|
||||||
&& ln -s /usr/local/lib/node_modules/@colbymchenry/codegraph/npm-shim.js \
|
|
||||||
/usr/local/bin/codegraph
|
/usr/local/bin/codegraph
|
||||||
|
|
||||||
WORKDIR /opt/agentci
|
WORKDIR /opt/agentci
|
||||||
@@ -50,16 +49,28 @@ COPY pyproject.toml uv.lock README.md ./
|
|||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
COPY install-scripts /etc/agentci/install-scripts
|
COPY install-scripts /etc/agentci/install-scripts
|
||||||
COPY codex/config.toml /etc/codex/config.toml
|
COPY opencode /etc/opencode
|
||||||
|
|
||||||
RUN chmod 0755 \
|
RUN chmod 0755 \
|
||||||
/opt/agentci/scripts/entrypoint.sh \
|
/opt/agentci/scripts/entrypoint.sh \
|
||||||
/opt/agentci/scripts/gitea-askpass.sh \
|
/opt/agentci/scripts/gitea-askpass.sh \
|
||||||
/usr/local/bin/tea \
|
/opt/agentci/scripts/tea.sh \
|
||||||
/usr/local/bin/codex \
|
/usr/local/libexec/tea \
|
||||||
|
/usr/local/bin/opencode \
|
||||||
/usr/local/bin/codegraph \
|
/usr/local/bin/codegraph \
|
||||||
|
&& ln -s /opt/agentci/scripts/tea.sh /usr/local/bin/tea \
|
||||||
&& uv sync --frozen --no-dev \
|
&& uv sync --frozen --no-dev \
|
||||||
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/codex
|
&& chown -R agentci:agentci /opt/agentci /var/lib/agentci /var/lib/opencode \
|
||||||
|
&& opencode --version \
|
||||||
|
&& OPENCODE_CONFIG=/etc/opencode/opencode.json \
|
||||||
|
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
|
||||||
|
OPENCODE_PURE=1 \
|
||||||
|
AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna \
|
||||||
|
AGENTCI_EXPLORE_VARIANT=low \
|
||||||
|
AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \
|
||||||
|
AGENTCI_RESEARCH_VARIANT=high \
|
||||||
|
CONTEXT7_API_KEY= \
|
||||||
|
opencode debug config >/dev/null
|
||||||
|
|
||||||
USER agentci
|
USER agentci
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Agent CI
|
# Agent CI
|
||||||
|
|
||||||
Agent CI is a private Gitea webhook host that turns issue and pull-request
|
Agent CI is a private Gitea webhook host that turns issue and pull-request comments into resumable
|
||||||
comments into resumable Codex planning and implementation workflows. It runs as
|
OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a
|
||||||
one persistent Docker Compose service on the same Docker network as Gitea.
|
private OpenCode server on the same Docker network as Gitea.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -15,68 +15,93 @@ one persistent Docker Compose service on the same Docker network as Gitea.
|
|||||||
| PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. |
|
| PR | `/agent iterate [message]` | Resume an agent implementation and its reviewer once. |
|
||||||
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
|
| PR | `/agent fix [message]` | Start a fresh one-shot fix session and push one commit. |
|
||||||
|
|
||||||
Anyone who can comment on an issue or pull request can enqueue commands. Each
|
The requester must have Gitea `write`, `admin`, or `owner` permission on the repository. Commands
|
||||||
command gets separate queued and started comments. Final plans, PR results,
|
are durably sequenced when their webhook arrives, then authorized and executed in that receive
|
||||||
failures, and remaining review findings are posted separately.
|
order. Each command gets one Gitea comment, which is reconciled asynchronously through queued,
|
||||||
|
running, and terminal states. Deleted comments are rediscovered by their hidden marker or recreated.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
1. Create a Gitea bot user with repository read/write access and an API token.
|
1. Create a Gitea bot user with repository read/write access and an API token.
|
||||||
2. Copy `.env.example` to `.env` and set the external network and internal
|
2. Copy `.env.example` to `.env` and set the external network, Gitea URL, and provider-qualified
|
||||||
Gitea URL.
|
OpenCode models.
|
||||||
3. Create `secrets/gitea_token` containing the bot token and
|
3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and
|
||||||
`secrets/webhook_secret` containing a high-entropy webhook secret.
|
`secrets/opencode_server_password`. Use high-entropy values for both secret/password files.
|
||||||
4. Build and start the service:
|
4. Build the image:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up --build -d
|
docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Authenticate Codex interactively in the persistent container:
|
5. Authenticate the configured OpenCode providers before starting the persistent server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose exec agentci codex login --device-auth
|
docker compose run --rm opencode opencode auth login
|
||||||
docker compose exec agentci codex login status
|
docker compose run --rm opencode opencode auth list
|
||||||
```
|
```
|
||||||
|
|
||||||
6. In Gitea, create a JSON webhook targeting
|
6. Start the services:
|
||||||
`http://agentci:8080/webhooks/gitea`. Set the same secret and subscribe to
|
|
||||||
issue comments, PR timeline comments, and PR review comments.
|
|
||||||
|
|
||||||
`/health/live` reports process health. `/health/ready` returns 503 until Codex
|
```sh
|
||||||
authentication is usable. The worker leaves jobs queued while authentication is
|
docker compose up --no-build -d
|
||||||
missing.
|
```
|
||||||
|
|
||||||
## Configuration
|
7. In Gitea, create a JSON webhook targeting `http://agentci:8080/webhooks/gitea`. Set the same
|
||||||
|
webhook secret and subscribe to issue comments, PR timeline comments, and PR review comments.
|
||||||
|
|
||||||
Model, reasoning effort, review-pass counts, bot identity, branch prefix, and
|
OpenCode caches provider state. After adding or changing authentication on an already running
|
||||||
turn timeout use `AGENTCI_` environment variables. Defaults are shown in
|
deployment, run the one-off `auth login` command above and then `docker compose restart opencode`.
|
||||||
`.env.example`. Gitea credentials and webhook secrets are intentionally
|
|
||||||
file-based Compose secrets.
|
|
||||||
|
|
||||||
Every planning and implementation session can delegate external research to a
|
`/health/live` reports process health. `/health/ready` returns 503 until the OpenCode server is
|
||||||
read-only `research` subagent. It defaults to `gpt-5.6-luna` with high reasoning
|
healthy and every configured model exists, supports tool calls, accepts its configured variant, and
|
||||||
and has public network access, live web search, Context7 documentation lookup,
|
has a connected provider. The worker leaves jobs queued while the runtime is unavailable.
|
||||||
and `gh_grep` public GitHub code search. Configure its model and effort with
|
|
||||||
`AGENTCI_RESEARCH_MODEL` and `AGENTCI_RESEARCH_REASONING`. Context7 works
|
|
||||||
without authentication at lower rate limits; set the optional
|
|
||||||
`AGENTCI_CONTEXT7_API_KEY` for authenticated usage. The key is passed only to
|
|
||||||
Codex's Context7 MCP transport and is excluded from agent shell environments.
|
|
||||||
|
|
||||||
Planning, implementation, and all review sessions also receive the local
|
## OpenCode
|
||||||
CodeGraph MCP server for repository structure, symbol relationships, and change
|
|
||||||
impact. Agent CI initializes or refreshes the index before every Codex turn and
|
|
||||||
locally excludes `.codegraph/` from Git. The research subagent intentionally
|
|
||||||
does not receive CodeGraph.
|
|
||||||
|
|
||||||
### Development environments
|
Models use OpenCode's `provider/model` format. Planning, implementation, and research can use
|
||||||
|
different providers. Optional `AGENTCI_PLAN_VARIANT` and `AGENTCI_IMPLEMENT_VARIANT` values are
|
||||||
|
passed directly to OpenCode for providers that support variants. `AGENTCI_RESEARCH_VARIANT`
|
||||||
|
configures the research subagent and defaults to `high`. `AGENTCI_EXPLORE_MODEL` and
|
||||||
|
`AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to
|
||||||
|
`openai/gpt-5.6-luna` with `low`.
|
||||||
|
|
||||||
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development
|
`AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults
|
||||||
environment installers. The supplied `python` and `dotnet` scripts install only
|
to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker
|
||||||
their runtimes; they are ordinary scripts that can be replaced or removed.
|
may reuse the cached installation layer until the configured version or build inputs change.
|
||||||
Implementation agents remain responsible for restoring project dependencies
|
Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built.
|
||||||
and selecting build/test commands. Configure the supplied scripts with
|
|
||||||
`AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
|
The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent
|
||||||
|
permission policies remain in effect; Agent CI does not replace them with an allow-all policy.
|
||||||
|
Repository-local OpenCode config and external plugins are disabled so a clone cannot replace the
|
||||||
|
service policy. OpenCode's default plugins remain enabled for provider authentication. Its scanned
|
||||||
|
home and global configuration paths are root-owned and read-only, and external skill discovery is
|
||||||
|
disabled, so an agent cannot persist instructions for later repositories. CodeGraph, Context7, and
|
||||||
|
`gh_grep` are configured as MCP servers. Set `AGENTCI_CONTEXT7_API_KEY` to raise Context7 rate
|
||||||
|
limits.
|
||||||
|
|
||||||
|
The `research` subagent can only use Exa web search, Context7, and the `gh_grep` public-code search
|
||||||
|
MCP. All filesystem, shell, editing, task, and other tools are denied for that agent. Exa is enabled
|
||||||
|
with `OPENCODE_ENABLE_EXA=1`. Agent CI initializes or refreshes CodeGraph before every parent turn
|
||||||
|
and locally excludes `.codegraph/` from Git.
|
||||||
|
|
||||||
|
### Security boundary
|
||||||
|
|
||||||
|
OpenCode does not provide an OS sandbox. Its default permission system controls agent tools and
|
||||||
|
approval requests, while Docker limits what the non-root process can reach. Any shell command that
|
||||||
|
OpenCode permits still has the Unix-level access of that container user, so environment filtering
|
||||||
|
is only accidental-exposure hygiene and cannot protect readable files from an allowed shell command.
|
||||||
|
|
||||||
|
Docker remains the OS boundary. The services run as non-root without added capabilities,
|
||||||
|
privileged mode, an unconfined seccomp/AppArmor profile, or a nested `bubblewrap` sandbox. The only
|
||||||
|
host bind mount is the read-only installer directory; there are no
|
||||||
|
writable host filesystem mounts. The OpenCode HTTP server is not published to the host, is password
|
||||||
|
protected, and is reachable by Agent CI over an internal Compose network.
|
||||||
|
|
||||||
|
## Development environments
|
||||||
|
|
||||||
|
`AGENTCI_INSTALL_SCRIPTS` is a comma-delimited ordered list of development environment installers.
|
||||||
|
The supplied `python` and `dotnet` scripts install only their runtimes; they can be replaced or
|
||||||
|
removed. Configure them with `AGENTCI_PYTHON_VERSION` and `AGENTCI_DOTNET_CHANNEL`:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
|
AGENTCI_INSTALL_SCRIPTS=python,dotnet,company-tools
|
||||||
@@ -84,44 +109,36 @@ AGENTCI_PYTHON_VERSION=3.13
|
|||||||
AGENTCI_DOTNET_CHANNEL=10.0
|
AGENTCI_DOTNET_CHANNEL=10.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Every name resolves to an executable file in `install-scripts/`, mounted
|
Every name resolves to a file in `install-scripts/`, mounted read-only at
|
||||||
read-only at `/etc/agentci/install-scripts`. Names cannot contain paths and
|
`/etc/agentci/install-scripts`. Names cannot contain paths and duplicates are rejected. Installers
|
||||||
duplicates are rejected. See `install-scripts/README.md` for the script contract.
|
run after each implementation clone or branch sync and fail the job on an unknown script, timeout,
|
||||||
|
or non-zero exit. They receive no Agent CI or Gitea secret values in their environment, but remain
|
||||||
|
trusted operator code. Tools persist under `/var/lib/agentci/dev-tools`, and OpenCode can read or
|
||||||
|
modify them through shell commands permitted by its active agent policy. See
|
||||||
|
`install-scripts/README.md` for the script contract.
|
||||||
|
|
||||||
Installers run in order after each implementation clone or branch sync and fail
|
Agent CI continues to create branches, validate diffs, commit, and push after OpenCode returns. This
|
||||||
the job on an unknown script, timeout, or non-zero exit. They receive no AgentCI
|
keeps workflow behavior deterministic, but an OpenCode agent with shell permission can still run
|
||||||
or Gitea secret values in their environment, but remain trusted operator code
|
Git commands itself.
|
||||||
running as the service user. Tools persist under `/var/lib/agentci/dev-tools`;
|
|
||||||
its `bin` directory is added to implementation agents' `PATH` with read-only
|
|
||||||
sandbox access. The agents can use those tools for builds and validation, but
|
|
||||||
Agent CI does not impose host-side build commands.
|
|
||||||
|
|
||||||
Planning/review commands can only read their workflow clone and have no shell
|
|
||||||
network access. Implementation/fix commands can edit the clone but cannot
|
|
||||||
modify `.git`; they can reach public internet destinations while private and
|
|
||||||
loopback destinations remain blocked. Codex's interactive Git trust check is
|
|
||||||
skipped because every turn runs non-interactively against a service-owned clone;
|
|
||||||
the configured filesystem and network permissions still apply. Git credentials
|
|
||||||
exist only in the service-owned clone/push subprocess and are not inherited by
|
|
||||||
Codex turns. The image and Compose capability/security settings let the non-root
|
|
||||||
service create Codex's nested `bwrap` sandbox. They follow Codex's secure
|
|
||||||
devcontainer pattern instead of making the service container privileged. Do not
|
|
||||||
remove Codex's configured filesystem and network restrictions. In particular,
|
|
||||||
`systempaths=unconfined` removes Docker's outer masked `/proc` subpaths so the
|
|
||||||
nested user/PID namespace can mount its own procfs. Runtimes such as CoreCLR
|
|
||||||
require `/proc/self/maps`; Codex still controls visibility through the fresh
|
|
||||||
procfs and its filesystem policy.
|
|
||||||
|
|
||||||
## State and recovery
|
## State and recovery
|
||||||
|
|
||||||
The `agentci_data` volume contains SQLite, persistent workflow clones, and
|
The `agentci_data` volume contains SQLite, workflow clones, and installed development runtimes.
|
||||||
installed development runtimes.
|
The `opencode_home` volume contains provider authentication, OpenCode's database, and resumable
|
||||||
`codex_home` contains login state and resumable Codex sessions. Both are kept
|
sessions. Tea's Gitea token configuration is regenerated in an ephemeral tmpfs and is not copied to
|
||||||
indefinitely and should be backed up together.
|
`opencode_home`. Back up both persistent volumes together.
|
||||||
|
|
||||||
Queued jobs survive restart. An in-progress job is marked failed after restart
|
SQLite stores the current job state, an idempotent event inbox, and durable listener tasks. State
|
||||||
instead of being replayed, because replaying a partially completed model turn
|
transitions and workflow creation/linking commit atomically; timestamps are storage metadata rather
|
||||||
could duplicate changes. Git pushes are never forced.
|
than reducer state. Control effects retry with bounded backoff. A delayed authorization blocks later
|
||||||
|
workflow execution but not later control work.
|
||||||
|
|
||||||
|
The OpenCode migration tags existing workflows as Codex-owned and preserves their session IDs for
|
||||||
|
rollback, but OpenCode refuses to resume them. Follow-up commands against those workflows ask for a
|
||||||
|
new plan or implementation. Queued jobs survive restart. A restart before `JobStarted` returns the
|
||||||
|
task to the queue; after `JobStarted`, the job is failed, its sessions are aborted, and execution is
|
||||||
|
never replayed because a partial model turn may already have changed files. Git pushes are never
|
||||||
|
forced.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -132,7 +149,9 @@ uv sync
|
|||||||
uv run ruff check .
|
uv run ruff check .
|
||||||
uv run pyright
|
uv run pyright
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
docker compose config
|
||||||
|
docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON
|
The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside
|
||||||
schemas live outside Python so orchestration modules remain small and readable.
|
Python so orchestration modules remain small and readable.
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
cli_auth_credentials_store = "file"
|
|
||||||
approval_policy = "never"
|
|
||||||
check_for_update_on_startup = false
|
|
||||||
web_search = "disabled"
|
|
||||||
default_permissions = "agentci-read"
|
|
||||||
developer_instructions = """
|
|
||||||
A custom research subagent named `research` is available in every workflow. Delegate focused
|
|
||||||
external research to it when current documentation, web evidence, or public code examples would
|
|
||||||
materially improve the plan or implementation. Keep repository analysis and edits in the parent.
|
|
||||||
Use CodeGraph for repository architecture, symbol relationships, and change-impact analysis when
|
|
||||||
it is useful. It is available to planning, implementation, and review agents, but not research.
|
|
||||||
"""
|
|
||||||
|
|
||||||
[mcp_servers.codegraph]
|
|
||||||
command = "codegraph"
|
|
||||||
args = ["serve", "--mcp"]
|
|
||||||
startup_timeout_sec = 60
|
|
||||||
tool_timeout_sec = 60
|
|
||||||
|
|
||||||
[mcp_servers.codegraph.env]
|
|
||||||
CODEGRAPH_TELEMETRY = "0"
|
|
||||||
|
|
||||||
[agents]
|
|
||||||
max_threads = 4
|
|
||||||
max_depth = 1
|
|
||||||
|
|
||||||
[shell_environment_policy]
|
|
||||||
inherit = "core"
|
|
||||||
exclude = ["*TOKEN*", "*SECRET*", "*KEY*", "AGENTCI_*", "GITEA_*"]
|
|
||||||
|
|
||||||
[permissions.agentci-read]
|
|
||||||
description = "Read a workflow repository without modifying it or using the network."
|
|
||||||
|
|
||||||
[permissions.agentci-read.filesystem]
|
|
||||||
":minimal" = "read"
|
|
||||||
glob_scan_max_depth = 5
|
|
||||||
|
|
||||||
[permissions.agentci-read.filesystem.":workspace_roots"]
|
|
||||||
"." = "read"
|
|
||||||
".git" = "read"
|
|
||||||
"**/*.env" = "deny"
|
|
||||||
|
|
||||||
[permissions.agentci-review]
|
|
||||||
description = "Review scoped workflow material and query Gitea with tea without editing files."
|
|
||||||
|
|
||||||
[permissions.agentci-review.filesystem]
|
|
||||||
":minimal" = "read"
|
|
||||||
glob_scan_max_depth = 5
|
|
||||||
|
|
||||||
[permissions.agentci-review.filesystem.":workspace_roots"]
|
|
||||||
"." = "read"
|
|
||||||
".git" = "read"
|
|
||||||
"**/*.env" = "deny"
|
|
||||||
|
|
||||||
[permissions.agentci-review.network]
|
|
||||||
enabled = true
|
|
||||||
allow_local_binding = false
|
|
||||||
|
|
||||||
[permissions.agentci-review.network.domains]
|
|
||||||
"*" = "allow"
|
|
||||||
|
|
||||||
[permissions.agentci-write]
|
|
||||||
description = "Edit a workflow repository without changing Git metadata."
|
|
||||||
|
|
||||||
[permissions.agentci-write.filesystem]
|
|
||||||
":minimal" = "read"
|
|
||||||
"/var/lib/agentci/dev-tools" = "read"
|
|
||||||
glob_scan_max_depth = 5
|
|
||||||
|
|
||||||
[permissions.agentci-write.filesystem.":workspace_roots"]
|
|
||||||
"." = "write"
|
|
||||||
".git" = "read"
|
|
||||||
"**/*.env" = "deny"
|
|
||||||
|
|
||||||
[permissions.agentci-write.network]
|
|
||||||
enabled = true
|
|
||||||
allow_local_binding = false
|
|
||||||
|
|
||||||
[permissions.agentci-write.network.domains]
|
|
||||||
"*" = "allow"
|
|
||||||
|
|
||||||
[permissions.agentci-research]
|
|
||||||
description = "Read a workflow repository and research public internet sources without editing."
|
|
||||||
|
|
||||||
[permissions.agentci-research.filesystem]
|
|
||||||
":minimal" = "read"
|
|
||||||
glob_scan_max_depth = 5
|
|
||||||
|
|
||||||
[permissions.agentci-research.filesystem.":workspace_roots"]
|
|
||||||
"." = "read"
|
|
||||||
".git" = "read"
|
|
||||||
"**/*.env" = "deny"
|
|
||||||
|
|
||||||
[permissions.agentci-research.network]
|
|
||||||
enabled = true
|
|
||||||
allow_local_binding = false
|
|
||||||
|
|
||||||
[permissions.agentci-research.network.domains]
|
|
||||||
"*" = "allow"
|
|
||||||
+72
-24
@@ -1,38 +1,31 @@
|
|||||||
services:
|
services:
|
||||||
agentci:
|
agentci:
|
||||||
|
image: agentci:local
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
CODEX_VERSION: ${CODEX_VERSION:-0.144.6}
|
AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}
|
||||||
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
|
CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1}
|
||||||
TEA_VERSION: ${TEA_VERSION:-0.14.2}
|
TEA_VERSION: ${TEA_VERSION:-0.14.2}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Codex applies its own bwrap sandbox inside this otherwise unprivileged container.
|
depends_on:
|
||||||
cap_add:
|
opencode:
|
||||||
- SYS_ADMIN
|
condition: service_healthy
|
||||||
- SYS_CHROOT
|
|
||||||
- SETUID
|
|
||||||
- SETGID
|
|
||||||
- SYS_PTRACE
|
|
||||||
- NET_ADMIN
|
|
||||||
- NET_RAW
|
|
||||||
security_opt:
|
|
||||||
- seccomp=unconfined
|
|
||||||
- apparmor=unconfined
|
|
||||||
# Docker's masked /proc paths prevent Codex's user namespace from mounting fresh procfs.
|
|
||||||
- systempaths=unconfined
|
|
||||||
environment:
|
environment:
|
||||||
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
|
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
|
||||||
AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci}
|
AGENTCI_BOT_USERNAME: ${AGENTCI_BOT_USERNAME:-agentci}
|
||||||
AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI}
|
AGENTCI_BOT_NAME: ${AGENTCI_BOT_NAME:-Agent CI}
|
||||||
AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost}
|
AGENTCI_BOT_EMAIL: ${AGENTCI_BOT_EMAIL:-agentci@localhost}
|
||||||
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-gpt-5.6-sol}
|
AGENTCI_OPENCODE_URL: http://opencode:4096
|
||||||
AGENTCI_PLAN_REASONING: ${AGENTCI_PLAN_REASONING:-medium}
|
AGENTCI_OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
|
||||||
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-gpt-5.6-sol}
|
AGENTCI_PLAN_MODEL: ${AGENTCI_PLAN_MODEL:-openai/gpt-5.6-sol}
|
||||||
AGENTCI_IMPLEMENT_REASONING: ${AGENTCI_IMPLEMENT_REASONING:-high}
|
AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-}
|
||||||
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-gpt-5.6-luna}
|
AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol}
|
||||||
AGENTCI_RESEARCH_REASONING: ${AGENTCI_RESEARCH_REASONING:-high}
|
AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-}
|
||||||
AGENTCI_CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-}
|
AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna}
|
||||||
|
AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}
|
||||||
|
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
|
||||||
|
AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}
|
||||||
AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4}
|
AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4}
|
||||||
AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
|
AGENTCI_IMPLEMENT_REVIEW_ROUNDS: ${AGENTCI_IMPLEMENT_REVIEW_ROUNDS:-3}
|
||||||
AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600}
|
AGENTCI_TURN_TIMEOUT_SECONDS: ${AGENTCI_TURN_TIMEOUT_SECONDS:-3600}
|
||||||
@@ -43,26 +36,81 @@ services:
|
|||||||
secrets:
|
secrets:
|
||||||
- gitea_token
|
- gitea_token
|
||||||
- webhook_secret
|
- webhook_secret
|
||||||
|
- opencode_server_password
|
||||||
volumes:
|
volumes:
|
||||||
- agentci_data:/var/lib/agentci
|
- agentci_data:/var/lib/agentci
|
||||||
- codex_home:/var/lib/codex
|
|
||||||
- ./install-scripts:/etc/agentci/install-scripts:ro
|
- ./install-scripts:/etc/agentci/install-scripts:ro
|
||||||
|
tmpfs:
|
||||||
|
- /run/agentci:mode=1777
|
||||||
expose:
|
expose:
|
||||||
- "8080"
|
- "8080"
|
||||||
networks:
|
networks:
|
||||||
- gitea
|
- gitea
|
||||||
|
- agentci_control
|
||||||
|
|
||||||
|
opencode:
|
||||||
|
image: agentci:local
|
||||||
|
command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
HOME: /etc/opencode/home
|
||||||
|
XDG_CONFIG_HOME: /etc/opencode/xdg
|
||||||
|
XDG_DATA_HOME: /var/lib/opencode/data
|
||||||
|
XDG_CACHE_HOME: /var/lib/opencode/cache
|
||||||
|
XDG_STATE_HOME: /var/lib/opencode/state
|
||||||
|
OPENCODE_CONFIG: /etc/opencode/opencode.json
|
||||||
|
OPENCODE_DISABLE_PROJECT_CONFIG: "1"
|
||||||
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1"
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1"
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE: "1"
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: "1"
|
||||||
|
OPENCODE_ENABLE_EXA: "1"
|
||||||
|
OPENCODE_PURE: "1"
|
||||||
|
OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode}
|
||||||
|
OPENCODE_SERVER_PASSWORD_FILE: /run/secrets/opencode_server_password
|
||||||
|
AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000}
|
||||||
|
AGENTCI_TEA_CONFIG_HOME: /run/agentci
|
||||||
|
AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna}
|
||||||
|
AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}
|
||||||
|
AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna}
|
||||||
|
AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}
|
||||||
|
CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-}
|
||||||
|
PATH: /var/lib/agentci/dev-tools/bin:/opt/agentci/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
secrets:
|
||||||
|
- gitea_token
|
||||||
|
- opencode_server_password
|
||||||
|
volumes:
|
||||||
|
- agentci_data:/var/lib/agentci
|
||||||
|
- opencode_home:/var/lib/opencode
|
||||||
|
tmpfs:
|
||||||
|
- /run/agentci:mode=1777
|
||||||
|
expose:
|
||||||
|
- "4096"
|
||||||
|
networks:
|
||||||
|
- gitea
|
||||||
|
- agentci_control
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl --fail --silent --user \"$${OPENCODE_SERVER_USERNAME}:$$(cat $${OPENCODE_SERVER_PASSWORD_FILE})\" http://127.0.0.1:4096/global/health >/dev/null"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
gitea_token:
|
gitea_token:
|
||||||
file: ./secrets/gitea_token
|
file: ./secrets/gitea_token
|
||||||
webhook_secret:
|
webhook_secret:
|
||||||
file: ./secrets/webhook_secret
|
file: ./secrets/webhook_secret
|
||||||
|
opencode_server_password:
|
||||||
|
file: ./secrets/opencode_server_password
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
agentci_data:
|
agentci_data:
|
||||||
codex_home:
|
opencode_home:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gitea:
|
gitea:
|
||||||
external: true
|
external: true
|
||||||
name: ${GITEA_NETWORK:-gitea}
|
name: ${GITEA_NETWORK:-gitea}
|
||||||
|
agentci_control:
|
||||||
|
internal: true
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ files beneath it and install command wrappers or symlinks into `$DEV_TOOLS_DIR/b
|
|||||||
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
|
directory is prepended to implementation agents' `PATH`. Environment changes made by a script do
|
||||||
not persist into implementation turns.
|
not persist into implementation turns.
|
||||||
|
|
||||||
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR`, but places the writable
|
The supplied `dotnet` wrapper keeps the SDK itself in `DEV_TOOLS_DIR` and places writable CLI state
|
||||||
.NET CLI home and NuGet package cache under `/tmp`. Codex implementation sandboxes do not expose a
|
and NuGet caches under `DEV_TOOLS_DIR/runtime/dotnet`. OpenCode's own home is deliberately read-only
|
||||||
normal user home and receive read-only access to installed tools, so runtime caches cannot live
|
so it cannot be used to persist global agent configuration.
|
||||||
beside the SDK.
|
|
||||||
|
|
||||||
The configured scripts run in list order after the repository is cloned (or an existing agent PR
|
The configured scripts run in list order after the repository is cloned (or an existing agent PR
|
||||||
branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and
|
branch is synchronized) and before the first implementation turn for `implement`, `iterate`, and
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ set -eu
|
|||||||
|
|
||||||
install_dir="$DEV_TOOLS_DIR/dotnet"
|
install_dir="$DEV_TOOLS_DIR/dotnet"
|
||||||
bin_dir="$DEV_TOOLS_DIR/bin"
|
bin_dir="$DEV_TOOLS_DIR/bin"
|
||||||
|
runtime_dir="$DEV_TOOLS_DIR/runtime/dotnet"
|
||||||
installer=$(mktemp)
|
installer=$(mktemp)
|
||||||
trap 'rm -f "$installer"' EXIT
|
trap 'rm -f "$installer"' EXIT
|
||||||
|
|
||||||
@@ -25,9 +26,9 @@ PYTHON
|
|||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
'#!/bin/sh' \
|
'#!/bin/sh' \
|
||||||
"export DOTNET_ROOT='$install_dir'" \
|
"export DOTNET_ROOT='$install_dir'" \
|
||||||
'export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' \
|
"export DOTNET_CLI_HOME='$runtime_dir/home'" \
|
||||||
'export NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' \
|
"export NUGET_PACKAGES='$runtime_dir/nuget/packages'" \
|
||||||
'export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"' \
|
"export NUGET_HTTP_CACHE_PATH='$runtime_dir/nuget/http-cache'" \
|
||||||
'export HOME="$DOTNET_CLI_HOME"' \
|
'export HOME="$DOTNET_CLI_HOME"' \
|
||||||
'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \
|
'export DOTNET_CLI_TELEMETRY_OPTOUT=1' \
|
||||||
'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \
|
'export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1' \
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"autoupdate": false,
|
||||||
|
"share": "disabled",
|
||||||
|
"subagent_depth": 1,
|
||||||
|
"instructions": ["/etc/opencode/AGENTS.md"],
|
||||||
|
"agent": {
|
||||||
|
"explore": {
|
||||||
|
"model": "{env:AGENTCI_EXPLORE_MODEL}",
|
||||||
|
"variant": "{env:AGENTCI_EXPLORE_VARIANT}"
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"description": "Research current documentation, web evidence, and public code examples.",
|
||||||
|
"mode": "subagent",
|
||||||
|
"model": "{env:AGENTCI_RESEARCH_MODEL}",
|
||||||
|
"variant": "{env:AGENTCI_RESEARCH_VARIANT}",
|
||||||
|
"permission": {
|
||||||
|
"*": "deny",
|
||||||
|
"websearch": "allow",
|
||||||
|
"context7_*": "allow",
|
||||||
|
"gh_grep_*": "allow"
|
||||||
|
},
|
||||||
|
"prompt": "Research external, current, or unfamiliar technical facts for the parent agent. Use Context7 for library documentation, gh_grep for public-code examples, and web search for primary sources or broader verification. Prefer authoritative sources, report links, distinguish facts from inference, and return a concise evidence-focused summary. Do not include secrets or proprietary source in external queries."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"codegraph": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["codegraph", "serve", "--mcp"],
|
||||||
|
"environment": {
|
||||||
|
"CODEGRAPH_TELEMETRY": "0"
|
||||||
|
},
|
||||||
|
"enabled": true,
|
||||||
|
"timeout": 60000
|
||||||
|
},
|
||||||
|
"context7": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
"headers": {
|
||||||
|
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"gh_grep": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.grep.app",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "agentci"
|
name = "agentci"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Gitea webhook host that orchestrates resumable Codex workflows"
|
description = "A Gitea webhook host that orchestrates resumable OpenCode workflows"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
python -c '
|
if [ -f "${OPENCODE_SERVER_PASSWORD_FILE:-}" ]; then
|
||||||
|
export OPENCODE_SERVER_PASSWORD
|
||||||
|
OPENCODE_SERVER_PASSWORD=$(cat "$OPENCODE_SERVER_PASSWORD_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${AGENTCI_GITEA_TOKEN_FILE:-/run/secrets/gitea_token}" ]; then
|
||||||
|
python -c '
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "tea"
|
config_dir = Path(os.environ.get("AGENTCI_TEA_CONFIG_HOME", "/run/agentci")) / "tea"
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
config_path = config_dir / "config.yml"
|
config_path = config_dir / "config.yml"
|
||||||
token_path = Path(
|
token_path = Path(
|
||||||
@@ -22,5 +28,6 @@ login = {
|
|||||||
config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
|
config_path.write_text(json.dumps({"logins": [login], "preferences": {}}))
|
||||||
config_path.chmod(0o600)
|
config_path.chmod(0o600)
|
||||||
'
|
'
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export XDG_CONFIG_HOME="${AGENTCI_TEA_CONFIG_HOME:-/run/agentci}"
|
||||||
|
exec /usr/local/libexec/tea "$@"
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
"""Gitea-triggered Codex workflow host."""
|
"""Gitea-triggered OpenCode workflow host."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -34,6 +34,13 @@ class GiteaClient:
|
|||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def has_write_permission(self, owner: str, repo: str, username: str) -> bool:
|
||||||
|
response = await self._request(
|
||||||
|
"GET", f"/repos/{owner}/{repo}/collaborators/{username}/permission"
|
||||||
|
)
|
||||||
|
permission = str(response.json().get("permission", "")).lower()
|
||||||
|
return permission in {"write", "admin", "owner"}
|
||||||
|
|
||||||
async def repository(self, owner: str, repo: str) -> RepositoryInfo:
|
async def repository(self, owner: str, repo: str) -> RepositoryInfo:
|
||||||
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
|
data = (await self._request("GET", f"/repos/{owner}/{repo}")).json()
|
||||||
return RepositoryInfo(
|
return RepositoryInfo(
|
||||||
@@ -98,6 +105,15 @@ class GiteaClient:
|
|||||||
)
|
)
|
||||||
return int(response.json()["id"])
|
return int(response.json()["id"])
|
||||||
|
|
||||||
|
async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool:
|
||||||
|
response = await self._request(
|
||||||
|
"PATCH",
|
||||||
|
f"/repos/{owner}/{repo}/issues/comments/{comment_id}",
|
||||||
|
json={"body": body},
|
||||||
|
allow_not_found=True,
|
||||||
|
)
|
||||||
|
return response.status_code != 404
|
||||||
|
|
||||||
async def create_pull_request(
|
async def create_pull_request(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|||||||
+178
-162
@@ -1,145 +1,177 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from agentci.adapters.database import Database, now
|
from agentci.adapters.database import Database, now
|
||||||
from agentci.domain.models import Job, JobKind, JobStatus
|
from agentci.adapters.state_persistence import (
|
||||||
|
insert_event as _insert_event,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
insert_state as _insert_state,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
insert_tasks as _insert_tasks,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
insert_workflow as _insert_workflow,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
optional_state as _optional_state,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
replace_state as _replace_state,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
state as _state,
|
||||||
|
)
|
||||||
|
from agentci.adapters.state_persistence import (
|
||||||
|
state_from_row as _state_from_row,
|
||||||
|
)
|
||||||
|
from agentci.domain.events import CommandReceived, JobEvent, WorkflowCreated
|
||||||
|
from agentci.domain.models import CommandEvent
|
||||||
|
from agentci.domain.state_machine import JobState, next_state
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EvolveResult:
|
||||||
|
state: JobState
|
||||||
|
duplicate: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ListenerTask:
|
||||||
|
id: int
|
||||||
|
job_id: str
|
||||||
|
source_event_id: str
|
||||||
|
listener: str
|
||||||
|
queue: str
|
||||||
|
attempts: int
|
||||||
|
|
||||||
|
|
||||||
class JobStore(Database):
|
class JobStore(Database):
|
||||||
async def record_delivery(self, delivery_id: str, comment_id: int) -> bool:
|
async def receive(
|
||||||
def record(connection: sqlite3.Connection) -> bool:
|
self, event_id: str, job_id: str, incoming: CommandEvent
|
||||||
try:
|
) -> EvolveResult:
|
||||||
connection.execute(
|
def operation(connection: sqlite3.Connection) -> EvolveResult:
|
||||||
"INSERT INTO deliveries VALUES (?, ?, ?)",
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
(delivery_id, comment_id, now()),
|
duplicate = connection.execute(
|
||||||
)
|
"SELECT job_id FROM job_events WHERE event_id=?", (event_id,)
|
||||||
except sqlite3.IntegrityError:
|
).fetchone()
|
||||||
return False
|
if duplicate:
|
||||||
return True
|
state = _state(connection, duplicate["job_id"])
|
||||||
|
connection.commit()
|
||||||
return await self._run(record)
|
return EvolveResult(state, True)
|
||||||
|
sequence = connection.execute(
|
||||||
async def enqueue(self, delivery_id: str, job: Job) -> bool:
|
"SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs"
|
||||||
return await self._run(lambda connection: self._enqueue(connection, delivery_id, job))
|
).fetchone()[0]
|
||||||
|
event = CommandReceived(
|
||||||
@staticmethod
|
job_id=job_id,
|
||||||
def _enqueue(connection: sqlite3.Connection, delivery_id: str, job: Job) -> bool:
|
delivery_id=incoming.delivery_id,
|
||||||
try:
|
receive_sequence=sequence,
|
||||||
with connection:
|
command_body=incoming.body,
|
||||||
connection.execute(
|
target_key=incoming.target_key,
|
||||||
"INSERT INTO deliveries VALUES (?, ?, ?)",
|
repo_owner=incoming.repo_owner,
|
||||||
(delivery_id, job.comment_id, now()),
|
repo_name=incoming.repo_name,
|
||||||
)
|
issue_number=incoming.issue_number,
|
||||||
connection.execute(
|
pr_number=incoming.pr_number,
|
||||||
"""
|
requester=incoming.requester,
|
||||||
INSERT INTO jobs (
|
comment_id=incoming.comment_id,
|
||||||
id, kind, target_key, repo_owner, repo_name, issue_number,
|
)
|
||||||
pr_number, requester, message, comment_id, workflow_id,
|
transition = next_state(None, event)
|
||||||
status, stage, created_at
|
timestamp = now()
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
_insert_event(connection, event_id, event, timestamp)
|
||||||
""",
|
_insert_state(connection, transition.state, timestamp)
|
||||||
(
|
_insert_tasks(connection, event_id, transition, timestamp)
|
||||||
job.id,
|
|
||||||
job.kind,
|
|
||||||
job.target_key,
|
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
job.pr_number,
|
|
||||||
job.requester,
|
|
||||||
job.message,
|
|
||||||
job.comment_id,
|
|
||||||
job.workflow_id,
|
|
||||||
job.status,
|
|
||||||
job.stage,
|
|
||||||
now(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def claim_next(self) -> Job | None:
|
|
||||||
return await self._run(self._claim_next)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _claim_next(connection: sqlite3.Connection) -> Job | None:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT * FROM jobs WHERE status = ? ORDER BY created_at LIMIT 1",
|
|
||||||
(JobStatus.QUEUED,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return None
|
return EvolveResult(transition.state, False)
|
||||||
connection.execute(
|
|
||||||
"UPDATE jobs SET status = ?, stage = ?, started_at = ? WHERE id = ?",
|
|
||||||
(JobStatus.RUNNING, "starting", now(), row["id"]),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return job_from_row(row, status=JobStatus.RUNNING, stage="starting")
|
|
||||||
|
|
||||||
async def update_job(
|
return await self._run(operation)
|
||||||
self,
|
|
||||||
job_id: str,
|
|
||||||
*,
|
|
||||||
status: JobStatus | None = None,
|
|
||||||
stage: str | None = None,
|
|
||||||
error: str | None = None,
|
|
||||||
workflow_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
updates: dict[str, object] = {}
|
|
||||||
if status is not None:
|
|
||||||
updates["status"] = status
|
|
||||||
if status in {JobStatus.SUCCEEDED, JobStatus.FAILED, JobStatus.REJECTED}:
|
|
||||||
updates["finished_at"] = now()
|
|
||||||
if stage is not None:
|
|
||||||
updates["stage"] = stage
|
|
||||||
if error is not None:
|
|
||||||
updates["error"] = error
|
|
||||||
if workflow_id is not None:
|
|
||||||
updates["workflow_id"] = workflow_id
|
|
||||||
await self._update("jobs", job_id, updates)
|
|
||||||
log.info(
|
|
||||||
"job state updated",
|
|
||||||
extra={
|
|
||||||
"operation": "job.update",
|
|
||||||
"job_id": job_id,
|
|
||||||
"workflow_id": workflow_id,
|
|
||||||
"stage": stage,
|
|
||||||
"status_code": status.value if status is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def set_job_comment(self, job_id: str, column: str, comment_id: int) -> None:
|
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
|
||||||
if column not in {"accepted_comment_id", "started_comment_id"}:
|
def operation(connection: sqlite3.Connection) -> EvolveResult:
|
||||||
raise ValueError("Unsupported comment column")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
if connection.execute(
|
||||||
|
"SELECT 1 FROM job_events WHERE event_id=?", (event_id,)
|
||||||
|
).fetchone():
|
||||||
|
state = _state(connection, event.job_id)
|
||||||
|
connection.commit()
|
||||||
|
return EvolveResult(state, True)
|
||||||
|
current = _state(connection, event.job_id)
|
||||||
|
transition = next_state(current, event)
|
||||||
|
timestamp = now()
|
||||||
|
_insert_event(connection, event_id, event, timestamp)
|
||||||
|
if isinstance(event, WorkflowCreated):
|
||||||
|
_insert_workflow(connection, event, timestamp)
|
||||||
|
_replace_state(connection, transition.state, current, timestamp)
|
||||||
|
_insert_tasks(connection, event_id, transition, timestamp)
|
||||||
|
connection.commit()
|
||||||
|
return EvolveResult(transition.state, False)
|
||||||
|
|
||||||
|
return await self._run(operation)
|
||||||
|
|
||||||
|
async def get_job_state(self, job_id: str) -> JobState | None:
|
||||||
|
return await self._run(lambda connection: _optional_state(connection, job_id))
|
||||||
|
|
||||||
|
async def claim_task(self, queue: str) -> ListenerTask | None:
|
||||||
|
return await self._run(lambda connection: _claim_task(connection, queue))
|
||||||
|
|
||||||
|
async def complete_task(self, task_id: int) -> None:
|
||||||
await self._run(
|
await self._run(
|
||||||
lambda connection: connection.execute(
|
lambda connection: connection.execute(
|
||||||
f"UPDATE jobs SET {column} = ? WHERE id = ?", (comment_id, job_id)
|
"UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?",
|
||||||
|
(now(), task_id),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def job_stage(self, job_id: str) -> str:
|
async def retry_task(self, task_id: int, attempts: int, error: str) -> None:
|
||||||
def select(connection: sqlite3.Connection) -> str:
|
delay = min(2 ** min(attempts, 8), 300)
|
||||||
row = connection.execute("SELECT stage FROM jobs WHERE id=?", (job_id,)).fetchone()
|
available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat()
|
||||||
return str(row["stage"]) if row else "unknown"
|
await self._run(
|
||||||
|
lambda connection: connection.execute(
|
||||||
|
"UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?",
|
||||||
|
(available, error[:1000], task_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return await self._run(select)
|
async def running_job_states(self) -> list[JobState]:
|
||||||
|
return await self._run(
|
||||||
|
lambda connection: [
|
||||||
|
_state_from_row(row)
|
||||||
|
for row in connection.execute("SELECT * FROM jobs WHERE status='running'")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recover_tasks(self) -> None:
|
||||||
|
def recover(connection: sqlite3.Connection) -> None:
|
||||||
|
with connection:
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE listener_tasks SET status='pending', started_at=NULL
|
||||||
|
WHERE status='running' AND queue='control'"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE listener_tasks SET status='pending', started_at=NULL
|
||||||
|
WHERE status='running' AND listener='execute' AND job_id IN
|
||||||
|
(SELECT id FROM jobs WHERE status='queued')"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE listener_tasks SET status='failed', finished_at=?,
|
||||||
|
error='Service restarted after execution began'
|
||||||
|
WHERE status='running' AND listener='execute' AND job_id IN
|
||||||
|
(SELECT id FROM jobs WHERE status<>'queued')""",
|
||||||
|
(now(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._run(recover)
|
||||||
|
|
||||||
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
|
async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]:
|
||||||
return await self._run(
|
return await self._run(
|
||||||
lambda connection: {
|
lambda connection: {
|
||||||
value
|
value
|
||||||
for row in connection.execute(
|
for row in connection.execute(
|
||||||
"""
|
"SELECT accepted_comment_id, started_comment_id FROM jobs "
|
||||||
SELECT accepted_comment_id, started_comment_id FROM jobs
|
"WHERE repo_owner=? AND repo_name=? AND issue_number=?",
|
||||||
WHERE repo_owner=? AND repo_name=? AND issue_number=?
|
|
||||||
""",
|
|
||||||
(owner, repo, issue),
|
(owner, repo, issue),
|
||||||
)
|
)
|
||||||
for value in row
|
for value in row
|
||||||
@@ -147,49 +179,33 @@ class JobStore(Database):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def recover_running(self) -> list[Job]:
|
|
||||||
def recover(connection: sqlite3.Connection) -> list[Job]:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT * FROM jobs WHERE status=?", (JobStatus.RUNNING,)
|
|
||||||
).fetchall()
|
|
||||||
with connection:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE jobs SET status=?, stage=?, error=?, finished_at=?
|
|
||||||
WHERE status=?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
JobStatus.FAILED,
|
|
||||||
"interrupted",
|
|
||||||
"Service restarted during an active Codex turn",
|
|
||||||
now(),
|
|
||||||
JobStatus.RUNNING,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return [job_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
return await self._run(recover)
|
def _claim_task(connection: sqlite3.Connection, queue: str) -> ListenerTask | None:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
fifo = ""
|
||||||
def job_from_row(
|
if queue == "jobs":
|
||||||
row: sqlite3.Row,
|
fifo = """AND NOT EXISTS (
|
||||||
*,
|
SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence
|
||||||
status: JobStatus | None = None,
|
AND earlier.status IN ('received', 'queued', 'running'))"""
|
||||||
stage: str | None = None,
|
row = connection.execute(
|
||||||
) -> Job:
|
f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id
|
||||||
return Job(
|
WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo}
|
||||||
id=row["id"],
|
ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""",
|
||||||
kind=JobKind(row["kind"]),
|
(queue, now()),
|
||||||
target_key=row["target_key"],
|
).fetchone()
|
||||||
repo_owner=row["repo_owner"],
|
if row is None:
|
||||||
repo_name=row["repo_name"],
|
connection.commit()
|
||||||
issue_number=row["issue_number"],
|
return None
|
||||||
pr_number=row["pr_number"],
|
changed = connection.execute(
|
||||||
requester=row["requester"],
|
"UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 "
|
||||||
message=row["message"],
|
"WHERE id=? AND status='pending'",
|
||||||
comment_id=row["comment_id"],
|
(now(), row["id"]),
|
||||||
workflow_id=row["workflow_id"],
|
)
|
||||||
status=status or JobStatus(row["status"]),
|
if changed.rowcount != 1:
|
||||||
stage=stage or row["stage"],
|
connection.rollback()
|
||||||
accepted_comment_id=row["accepted_comment_id"],
|
return None
|
||||||
|
connection.commit()
|
||||||
|
return ListenerTask(
|
||||||
|
row["id"], row["job_id"], row["source_event_id"], row["listener"],
|
||||||
|
row["queue"], row["attempts"] + 1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from agentci.adapters.codegraph import CodeGraphClient
|
||||||
|
from agentci.adapters.opencode_support import (
|
||||||
|
api_contract_ready,
|
||||||
|
directory_headers,
|
||||||
|
elapsed_ms,
|
||||||
|
error_message,
|
||||||
|
load_schema,
|
||||||
|
model_parts,
|
||||||
|
models_ready,
|
||||||
|
)
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=BaseModel)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
schemas_dir: Path,
|
||||||
|
health_directory: Path,
|
||||||
|
required_models: tuple[tuple[str, str | None], ...],
|
||||||
|
timeout_seconds: int,
|
||||||
|
codegraph: CodeGraphClient | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.schemas_dir = schemas_dir
|
||||||
|
self.health_directory = health_directory
|
||||||
|
self.required_models = {
|
||||||
|
(*model_parts(model), variant) for model, variant in required_models
|
||||||
|
}
|
||||||
|
self._contract_valid: bool | None = None
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.codegraph = codegraph or CodeGraphClient()
|
||||||
|
self._active_sessions: dict[str, Path] = {}
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=base_url.rstrip("/"),
|
||||||
|
auth=httpx.BasicAuth(username, password),
|
||||||
|
timeout=httpx.Timeout(timeout_seconds, connect=10),
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
for session_id, workspace in tuple(self._active_sessions.items()):
|
||||||
|
await self.abort(session_id, workspace, best_effort=True)
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def ready(self) -> bool:
|
||||||
|
try:
|
||||||
|
health = await self.client.get("/global/health", timeout=10)
|
||||||
|
health.raise_for_status()
|
||||||
|
if health.json().get("healthy") is not True:
|
||||||
|
return False
|
||||||
|
if not str(health.json().get("version", "")).startswith("1."):
|
||||||
|
return False
|
||||||
|
if self._contract_valid is None:
|
||||||
|
document = await self.client.get("/doc", timeout=10)
|
||||||
|
document.raise_for_status()
|
||||||
|
self._contract_valid = api_contract_ready(document.json())
|
||||||
|
if not self._contract_valid:
|
||||||
|
return False
|
||||||
|
providers = await self.client.get(
|
||||||
|
"/provider", headers=directory_headers(self.health_directory), timeout=10
|
||||||
|
)
|
||||||
|
providers.raise_for_status()
|
||||||
|
return models_ready(providers.json(), self.required_models)
|
||||||
|
except (httpx.HTTPError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
workspace: Path,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
variant: str | None,
|
||||||
|
schema_name: str,
|
||||||
|
result_type: type[T],
|
||||||
|
) -> tuple[str, T]:
|
||||||
|
session_id = await self.create_session(workspace, schema_name)
|
||||||
|
result = await self.resume(
|
||||||
|
session_id=session_id,
|
||||||
|
workspace=workspace,
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
variant=variant,
|
||||||
|
schema_name=schema_name,
|
||||||
|
result_type=result_type,
|
||||||
|
)
|
||||||
|
return session_id, result
|
||||||
|
|
||||||
|
async def create_session(self, workspace: Path, title: str) -> str:
|
||||||
|
response = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/session",
|
||||||
|
workspace=workspace,
|
||||||
|
json={"title": f"Agent CI: {title.removesuffix('.json')}"},
|
||||||
|
)
|
||||||
|
session_id = response.get("id")
|
||||||
|
if not isinstance(session_id, str) or not session_id:
|
||||||
|
raise OpenCodeError("OpenCode did not return a session ID")
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
async def resume(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
variant: str | None,
|
||||||
|
workspace: Path,
|
||||||
|
schema_name: str,
|
||||||
|
result_type: type[T],
|
||||||
|
) -> T:
|
||||||
|
await self.codegraph.prepare(workspace)
|
||||||
|
self._active_sessions[session_id] = workspace
|
||||||
|
try:
|
||||||
|
return await self._prompt(
|
||||||
|
session_id=session_id,
|
||||||
|
workspace=workspace,
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
variant=variant,
|
||||||
|
schema_name=schema_name,
|
||||||
|
result_type=result_type,
|
||||||
|
)
|
||||||
|
except (asyncio.CancelledError, OpenCodeError):
|
||||||
|
await asyncio.shield(self.abort(session_id, workspace))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._active_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
async def _prompt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
workspace: Path,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
variant: str | None,
|
||||||
|
schema_name: str,
|
||||||
|
result_type: type[T],
|
||||||
|
) -> T:
|
||||||
|
try:
|
||||||
|
schema = load_schema(self.schemas_dir, schema_name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise OpenCodeError(str(exc)) from exc
|
||||||
|
provider_id, model_id = model_parts(model)
|
||||||
|
repair = "The previous response did not produce the required structured result. "
|
||||||
|
repair += "Return the requested result now without repeating repository work."
|
||||||
|
for attempt, message in enumerate((prompt, repair), start=1):
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": {"providerID": provider_id, "modelID": model_id},
|
||||||
|
"agent": "build",
|
||||||
|
"parts": [{"type": "text", "text": message}],
|
||||||
|
"format": {"type": "json_schema", "schema": schema, "retryCount": 0},
|
||||||
|
}
|
||||||
|
if variant:
|
||||||
|
payload["variant"] = variant
|
||||||
|
started = monotonic()
|
||||||
|
extra = {"operation": "opencode.prompt", "attempt": attempt}
|
||||||
|
log.info("OpenCode turn started", extra=extra)
|
||||||
|
try:
|
||||||
|
response = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/session/{session_id}/message",
|
||||||
|
workspace=workspace,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
await self.abort(session_id, workspace)
|
||||||
|
message = f"OpenCode turn exceeded {self.timeout_seconds} seconds"
|
||||||
|
raise OpenCodeError(message) from exc
|
||||||
|
info = response.get("info")
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
raise OpenCodeError("OpenCode response did not include assistant metadata")
|
||||||
|
error = error_message(info.get("error"))
|
||||||
|
structured = info.get("structured")
|
||||||
|
validation_failed = False
|
||||||
|
if structured is not None:
|
||||||
|
try:
|
||||||
|
result = result_type.model_validate(structured)
|
||||||
|
except ValidationError as exc:
|
||||||
|
error = f"structured result failed validation: {exc}"
|
||||||
|
validation_failed = True
|
||||||
|
else:
|
||||||
|
extra["duration_ms"] = elapsed_ms(started)
|
||||||
|
log.info("OpenCode turn completed", extra=extra)
|
||||||
|
return result
|
||||||
|
if attempt == 2 or (
|
||||||
|
error and "StructuredOutput" not in error and not validation_failed
|
||||||
|
):
|
||||||
|
raise OpenCodeError(f"OpenCode did not return a valid result: {error or 'missing'}")
|
||||||
|
log.warning("OpenCode structured result will be retried", extra=extra)
|
||||||
|
raise OpenCodeError("OpenCode did not return a valid result")
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self, method: str, path: str, *,
|
||||||
|
workspace: Path,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
response = await self.client.request(
|
||||||
|
method, path, headers=directory_headers(workspace), json=json
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
detail = ""
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
detail = f": {exc.response.text[-2000:]}"
|
||||||
|
message = f"OpenCode request failed: {method} {path}{detail}"
|
||||||
|
raise OpenCodeError(message) from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise OpenCodeError(f"OpenCode returned an invalid response for {method} {path}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def abort(self, session_id: str, workspace: Path, *, best_effort: bool = False) -> None:
|
||||||
|
try:
|
||||||
|
response = await self.client.post(
|
||||||
|
f"/session/{session_id}/abort", headers=directory_headers(workspace), timeout=10
|
||||||
|
)
|
||||||
|
if response.is_success or response.status_code in {404, 409}:
|
||||||
|
return
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
if best_effort:
|
||||||
|
log.warning("OpenCode session could not be aborted", exc_info=exc)
|
||||||
|
return
|
||||||
|
raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from agentci.domain.events import JobEvent, WorkflowCreated
|
||||||
|
from agentci.domain.models import JobKind, JobStatus
|
||||||
|
from agentci.domain.state_machine import JobState, Transition
|
||||||
|
|
||||||
|
|
||||||
|
def insert_event(connection: sqlite3.Connection, event_id: str, event: JobEvent, ts: str) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO job_events VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(event_id, event.job_id, event.type, event.model_dump_json(), ts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_tasks(
|
||||||
|
connection: sqlite3.Connection, event_id: str, transition: Transition, timestamp: str
|
||||||
|
) -> None:
|
||||||
|
for ordinal, notification in enumerate(transition.notifications):
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, "
|
||||||
|
"available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
transition.state.id,
|
||||||
|
event_id,
|
||||||
|
ordinal,
|
||||||
|
notification.listener,
|
||||||
|
notification.queue,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _values(state: JobState) -> tuple[object, ...]:
|
||||||
|
return (
|
||||||
|
state.id, state.kind, state.target_key, state.repo_owner, state.repo_name,
|
||||||
|
state.issue_number, state.pr_number, state.requester, state.message, state.comment_id,
|
||||||
|
state.delivery_id, state.receive_sequence, state.command_body, state.workflow_id,
|
||||||
|
state.status, state.stage, state.error, state.runtime_session_id,
|
||||||
|
state.accepted_comment_id, state.comment_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_state(connection: sqlite3.Connection, state: JobState, timestamp: str) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number,
|
||||||
|
pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body,
|
||||||
|
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
|
||||||
|
comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(*_values(state), timestamp),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_state(
|
||||||
|
connection: sqlite3.Connection, state: JobState, previous: JobState, timestamp: str
|
||||||
|
) -> None:
|
||||||
|
started = (
|
||||||
|
timestamp
|
||||||
|
if previous.status is JobStatus.QUEUED and state.status is JobStatus.RUNNING
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED}
|
||||||
|
finished = timestamp if previous.status not in terminal and state.status in terminal else None
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?,
|
||||||
|
pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?,
|
||||||
|
command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?,
|
||||||
|
accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?),
|
||||||
|
finished_at=COALESCE(finished_at, ?) WHERE id=?""",
|
||||||
|
(*_values(state)[1:], started, finished, state.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def optional_state(connection: sqlite3.Connection, job_id: str) -> JobState | None:
|
||||||
|
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
|
return state_from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def state(connection: sqlite3.Connection, job_id: str) -> JobState:
|
||||||
|
value = optional_state(connection, job_id)
|
||||||
|
if value is None:
|
||||||
|
raise KeyError(f"Unknown job {job_id}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def state_from_row(row: sqlite3.Row) -> JobState:
|
||||||
|
return JobState(
|
||||||
|
id=row["id"], kind=JobKind(row["kind"]) if row["kind"] else None,
|
||||||
|
target_key=row["target_key"], repo_owner=row["repo_owner"], repo_name=row["repo_name"],
|
||||||
|
issue_number=row["issue_number"], pr_number=row["pr_number"], requester=row["requester"],
|
||||||
|
message=row["message"], comment_id=row["comment_id"], delivery_id=row["delivery_id"],
|
||||||
|
receive_sequence=row["receive_sequence"], command_body=row["command_body"],
|
||||||
|
workflow_id=row["workflow_id"], status=JobStatus(row["status"]), stage=row["stage"],
|
||||||
|
error=row["error"], runtime_session_id=row["runtime_session_id"],
|
||||||
|
accepted_comment_id=row["accepted_comment_id"], comment_body=row["comment_body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_workflow(connection: sqlite3.Connection, event: WorkflowCreated, ts: str) -> None:
|
||||||
|
workflow = event.workflow
|
||||||
|
connection.execute(
|
||||||
|
"""INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number,
|
||||||
|
base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact,
|
||||||
|
review_json, status, runtime, created_at, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
workflow.id, workflow.kind, workflow.repo_owner, workflow.repo_name,
|
||||||
|
workflow.issue_number, workflow.pr_number, workflow.base_sha, workflow.branch,
|
||||||
|
str(workflow.workspace_path), workflow.primary_session_id,
|
||||||
|
workflow.reviewer_session_id, workflow.artifact, workflow.review_json,
|
||||||
|
workflow.status, workflow.runtime, ts, ts,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -8,6 +8,15 @@ from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowStore(Database):
|
class WorkflowStore(Database):
|
||||||
|
async def get_workflow(self, workflow_id: str) -> Workflow | None:
|
||||||
|
return await self._run(
|
||||||
|
lambda connection: workflow_from_row(
|
||||||
|
connection.execute(
|
||||||
|
"SELECT * FROM workflows WHERE id=?", (workflow_id,)
|
||||||
|
).fetchone()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def create_workflow(self, workflow: Workflow) -> None:
|
async def create_workflow(self, workflow: Workflow) -> None:
|
||||||
timestamp = now()
|
timestamp = now()
|
||||||
await self._run(
|
await self._run(
|
||||||
@@ -16,9 +25,9 @@ class WorkflowStore(Database):
|
|||||||
INSERT INTO workflows (
|
INSERT INTO workflows (
|
||||||
id, kind, repo_owner, repo_name, issue_number, pr_number,
|
id, kind, repo_owner, repo_name, issue_number, pr_number,
|
||||||
base_sha, branch, workspace_path, primary_session_id,
|
base_sha, branch, workspace_path, primary_session_id,
|
||||||
reviewer_session_id, artifact, review_json, status,
|
reviewer_session_id, artifact, review_json, status, runtime,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
workflow.id,
|
workflow.id,
|
||||||
@@ -35,6 +44,7 @@ class WorkflowStore(Database):
|
|||||||
workflow.artifact,
|
workflow.artifact,
|
||||||
workflow.review_json,
|
workflow.review_json,
|
||||||
workflow.status,
|
workflow.status,
|
||||||
|
workflow.runtime,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
),
|
),
|
||||||
@@ -135,6 +145,7 @@ def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None:
|
|||||||
issue_number=row["issue_number"],
|
issue_number=row["issue_number"],
|
||||||
pr_number=row["pr_number"],
|
pr_number=row["pr_number"],
|
||||||
base_sha=row["base_sha"],
|
base_sha=row["base_sha"],
|
||||||
|
runtime=row["runtime"],
|
||||||
branch=row["branch"],
|
branch=row["branch"],
|
||||||
workspace_path=Path(row["workspace_path"]),
|
workspace_path=Path(row["workspace_path"]),
|
||||||
primary_session_id=row["primary_session_id"],
|
primary_session_id=row["primary_session_id"],
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ async def live() -> dict[str, str]:
|
|||||||
|
|
||||||
@router.get("/health/ready")
|
@router.get("/health/ready")
|
||||||
async def ready(request: Request, response: Response) -> dict[str, str]:
|
async def ready(request: Request, response: Response) -> dict[str, str]:
|
||||||
if not await request.app.state.container.codex.login_ready():
|
if not await request.app.state.container.opencode.ready():
|
||||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
return {"status": "not-ready", "reason": "codex is not authenticated"}
|
return {"status": "not-ready", "reason": "opencode provider is not connected"}
|
||||||
return {"status": "ready"}
|
return {"status": "ready"}
|
||||||
|
|
||||||
|
|||||||
+16
-48
@@ -5,12 +5,10 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
|
from agentci.domain.models import CommandEvent
|
||||||
from agentci.domain.models import CommandEvent, Job, JobStatus
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -54,7 +52,7 @@ async def webhook(request: Request) -> Response:
|
|||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc
|
||||||
if event is None or event.requester == container.settings.bot_username:
|
if event is None or event.requester.casefold() == container.settings.bot_username.casefold():
|
||||||
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
|
log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name})
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
return await _handle_command(container, event)
|
return await _handle_command(container, event)
|
||||||
@@ -69,55 +67,25 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
|
|||||||
extra = {"operation": "command.handle", "target": event.target_key}
|
extra = {"operation": "command.handle", "target": event.target_key}
|
||||||
if not event.body.strip().startswith("/agent"):
|
if not event.body.strip().startswith("/agent"):
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
if not event.delivery_id:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery")
|
||||||
log.info("agent command received", extra=extra)
|
log.info("agent command received", extra=extra)
|
||||||
try:
|
try:
|
||||||
command = parse_command(event.body)
|
result = await container.state_machine.receive(event)
|
||||||
except CommandError as exc:
|
except Exception:
|
||||||
log.info("agent command rejected: invalid syntax", extra=extra)
|
log.exception("could not persist command", extra=extra)
|
||||||
if await container.storage.record_delivery(event.delivery_id, event.comment_id):
|
raise
|
||||||
await container.gitea.create_comment(
|
if result.duplicate:
|
||||||
event.repo_owner, event.repo_name, event.issue_number, str(exc)
|
log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id})
|
||||||
)
|
|
||||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
|
||||||
if command is None:
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
try:
|
|
||||||
kind = resolve_job_kind(command, is_pull_request=event.is_pull_request)
|
|
||||||
except CommandError as exc:
|
|
||||||
log.info("agent command rejected: invalid target", extra=extra)
|
|
||||||
if await container.storage.record_delivery(event.delivery_id, event.comment_id):
|
|
||||||
await container.gitea.create_comment(
|
|
||||||
event.repo_owner, event.repo_name, event.issue_number, str(exc)
|
|
||||||
)
|
|
||||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
|
||||||
job = Job(
|
|
||||||
id=str(uuid4()),
|
|
||||||
kind=kind,
|
|
||||||
target_key=event.target_key,
|
|
||||||
repo_owner=event.repo_owner,
|
|
||||||
repo_name=event.repo_name,
|
|
||||||
issue_number=event.issue_number,
|
|
||||||
pr_number=event.pr_number,
|
|
||||||
requester=event.requester,
|
|
||||||
message=command.message,
|
|
||||||
comment_id=event.comment_id,
|
|
||||||
status=JobStatus.QUEUED,
|
|
||||||
stage="queued",
|
|
||||||
)
|
|
||||||
if not await container.storage.enqueue(event.delivery_id, job):
|
|
||||||
log.info("duplicate command ignored", extra={**extra, "job_id": job.id})
|
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
log.info(
|
log.info(
|
||||||
"agent job queued",
|
"agent command persisted",
|
||||||
extra={**extra, "job_id": job.id, "stage": job.kind.value},
|
extra={
|
||||||
|
**extra,
|
||||||
|
"job_id": result.state.id,
|
||||||
|
"receive_sequence": result.state.receive_sequence,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
comment_id = await container.gitea.create_comment(
|
|
||||||
event.repo_owner,
|
|
||||||
event.repo_name,
|
|
||||||
event.issue_number,
|
|
||||||
f"Agent job `{job.id}` queued (`{job.kind}`).",
|
|
||||||
)
|
|
||||||
await container.storage.set_job_comment(job.id, "accepted_comment_id", comment_id)
|
|
||||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
return Response(status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -37,8 +37,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
finally:
|
finally:
|
||||||
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
||||||
stop.set()
|
stop.set()
|
||||||
|
worker_task.cancel()
|
||||||
try:
|
try:
|
||||||
await worker_task
|
with suppress(asyncio.CancelledError):
|
||||||
|
await worker_task
|
||||||
finally:
|
finally:
|
||||||
await container.close()
|
await container.close()
|
||||||
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
||||||
|
|||||||
+27
-11
@@ -5,7 +5,7 @@ from functools import cached_property
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from pydantic import Field, SecretStr, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
INSTALL_SCRIPT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
@@ -21,38 +21,48 @@ class Settings(BaseSettings):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
data_dir: Path = Path("/var/lib/agentci")
|
data_dir: Path = Path("/var/lib/agentci")
|
||||||
codex_home: Path = Path("/var/lib/codex")
|
|
||||||
gitea_url: str = "http://gitea:3000"
|
gitea_url: str = "http://gitea:3000"
|
||||||
gitea_token_file: Path = Path("/run/secrets/gitea_token")
|
gitea_token_file: Path = Path("/run/secrets/gitea_token")
|
||||||
webhook_secret_file: Path = Path("/run/secrets/webhook_secret")
|
webhook_secret_file: Path = Path("/run/secrets/webhook_secret")
|
||||||
|
opencode_url: str = "http://opencode:4096"
|
||||||
|
opencode_server_username: str = "opencode"
|
||||||
|
opencode_server_password_file: Path = Path("/run/secrets/opencode_server_password")
|
||||||
bot_username: str = "agentci"
|
bot_username: str = "agentci"
|
||||||
bot_name: str = "Agent CI"
|
bot_name: str = "Agent CI"
|
||||||
bot_email: str = "agentci@localhost"
|
bot_email: str = "agentci@localhost"
|
||||||
branch_prefix: str = "agent"
|
branch_prefix: str = "agent"
|
||||||
askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh")
|
askpass_path: Path = Path("/opt/agentci/scripts/gitea-askpass.sh")
|
||||||
plan_model: str = "gpt-5.6-sol"
|
plan_model: str = "openai/gpt-5.6-sol"
|
||||||
plan_reasoning: str = "medium"
|
plan_variant: str | None = None
|
||||||
implement_model: str = "gpt-5.6-sol"
|
implement_model: str = "openai/gpt-5.6-sol"
|
||||||
implement_reasoning: str = "high"
|
implement_variant: str | None = None
|
||||||
research_model: str = "gpt-5.6-luna"
|
explore_model: str = "openai/gpt-5.6-luna"
|
||||||
research_reasoning: str = "high"
|
explore_variant: str = "low"
|
||||||
context7_api_key: SecretStr | None = None
|
research_model: str = "openai/gpt-5.6-luna"
|
||||||
|
research_variant: str = "high"
|
||||||
plan_review_rounds: int = Field(default=4, ge=1, le=20)
|
plan_review_rounds: int = Field(default=4, ge=1, le=20)
|
||||||
implement_review_rounds: int = Field(default=3, ge=1, le=20)
|
implement_review_rounds: int = Field(default=3, ge=1, le=20)
|
||||||
turn_timeout_seconds: int = Field(default=3600, ge=60)
|
turn_timeout_seconds: int = Field(default=3600, ge=60)
|
||||||
install_script_timeout_seconds: int = Field(default=900, ge=1)
|
install_script_timeout_seconds: int = Field(default=900, ge=1)
|
||||||
worker_poll_seconds: float = Field(default=1.0, ge=0.1)
|
worker_poll_seconds: float = Field(default=1.0, ge=0.1)
|
||||||
public_agent_network: bool = True
|
|
||||||
install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
install_scripts: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
||||||
install_scripts_dir: Path = Path("/etc/agentci/install-scripts")
|
install_scripts_dir: Path = Path("/etc/agentci/install-scripts")
|
||||||
python_version: str = "3.13"
|
python_version: str = "3.13"
|
||||||
dotnet_channel: str = "10.0"
|
dotnet_channel: str = "10.0"
|
||||||
|
|
||||||
@field_validator("gitea_url")
|
@field_validator("gitea_url", "opencode_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def strip_url(cls, value: str) -> str:
|
def strip_url(cls, value: str) -> str:
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
@field_validator("plan_model", "implement_model", "explore_model", "research_model")
|
||||||
|
@classmethod
|
||||||
|
def validate_opencode_model(cls, value: str) -> str:
|
||||||
|
provider, separator, model = value.partition("/")
|
||||||
|
if not separator or not provider or not model:
|
||||||
|
raise ValueError("OpenCode models must use provider/model format")
|
||||||
|
return value
|
||||||
|
|
||||||
@field_validator("install_scripts", mode="before")
|
@field_validator("install_scripts", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_install_scripts(cls, value: object) -> list[str]:
|
def parse_install_scripts(cls, value: object) -> list[str]:
|
||||||
@@ -77,6 +87,12 @@ class Settings(BaseSettings):
|
|||||||
def webhook_secret(self) -> bytes:
|
def webhook_secret(self) -> bytes:
|
||||||
return self._read_secret(self.webhook_secret_file, "webhook secret").encode()
|
return self._read_secret(self.webhook_secret_file, "webhook secret").encode()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def opencode_server_password(self) -> str:
|
||||||
|
return self._read_secret(
|
||||||
|
self.opencode_server_password_file, "OpenCode server password"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_path(self) -> Path:
|
def database_path(self) -> Path:
|
||||||
return self.data_dir / "agentci.sqlite3"
|
return self.data_dir / "agentci.sqlite3"
|
||||||
|
|||||||
+23
-16
@@ -4,13 +4,14 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.adapters.codex import CodexClient
|
|
||||||
from agentci.adapters.development import DevelopmentEnvironment
|
from agentci.adapters.development import DevelopmentEnvironment
|
||||||
from agentci.adapters.git import GitClient
|
from agentci.adapters.git import GitClient
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.adapters.gitea import GiteaClient
|
||||||
|
from agentci.adapters.opencode import OpenCodeClient
|
||||||
from agentci.adapters.storage import Storage
|
from agentci.adapters.storage import Storage
|
||||||
from agentci.config import Settings
|
from agentci.config import Settings
|
||||||
from agentci.prompts import PromptLibrary
|
from agentci.prompts import PromptLibrary
|
||||||
|
from agentci.state_machine import StateMachine
|
||||||
from agentci.worker import Worker
|
from agentci.worker import Worker
|
||||||
from agentci.workflows.common import Dependencies
|
from agentci.workflows.common import Dependencies
|
||||||
from agentci.workflows.context import ContextBuilder
|
from agentci.workflows.context import ContextBuilder
|
||||||
@@ -24,11 +25,13 @@ class Container:
|
|||||||
storage: Storage
|
storage: Storage
|
||||||
gitea: GiteaClient
|
gitea: GiteaClient
|
||||||
git: GitClient
|
git: GitClient
|
||||||
codex: CodexClient
|
opencode: OpenCodeClient
|
||||||
|
state_machine: StateMachine
|
||||||
worker: Worker
|
worker: Worker
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
log.info("container shutdown started", extra={"operation": "container.close"})
|
log.info("container shutdown started", extra={"operation": "container.close"})
|
||||||
|
await self.opencode.close()
|
||||||
await self.gitea.close()
|
await self.gitea.close()
|
||||||
log.info("container shutdown completed", extra={"operation": "container.close"})
|
log.info("container shutdown completed", extra={"operation": "container.close"})
|
||||||
|
|
||||||
@@ -38,9 +41,9 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
package_dir = Path(__file__).parent
|
package_dir = Path(__file__).parent
|
||||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.codex_home.mkdir(parents=True, exist_ok=True)
|
|
||||||
storage = Storage(settings.database_path, package_dir / "migrations")
|
storage = Storage(settings.database_path, package_dir / "migrations")
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
|
state_machine = StateMachine(storage)
|
||||||
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
|
gitea = GiteaClient(settings.gitea_url, settings.gitea_token)
|
||||||
git = GitClient(
|
git = GitClient(
|
||||||
gitea_url=settings.gitea_url,
|
gitea_url=settings.gitea_url,
|
||||||
@@ -50,18 +53,19 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
commit_name=settings.bot_name,
|
commit_name=settings.bot_name,
|
||||||
commit_email=settings.bot_email,
|
commit_email=settings.bot_email,
|
||||||
)
|
)
|
||||||
codex = CodexClient(
|
opencode = OpenCodeClient(
|
||||||
codex_home=settings.codex_home,
|
base_url=settings.opencode_url,
|
||||||
|
username=settings.opencode_server_username,
|
||||||
|
password=settings.opencode_server_password,
|
||||||
schemas_dir=package_dir / "prompts" / "schemas",
|
schemas_dir=package_dir / "prompts" / "schemas",
|
||||||
timeout_seconds=settings.turn_timeout_seconds,
|
health_directory=settings.workspaces_dir,
|
||||||
research_model=settings.research_model,
|
required_models=(
|
||||||
research_reasoning=settings.research_reasoning,
|
(settings.plan_model, settings.plan_variant),
|
||||||
context7_api_key=(
|
(settings.implement_model, settings.implement_variant),
|
||||||
settings.context7_api_key.get_secret_value()
|
(settings.explore_model, settings.explore_variant),
|
||||||
if settings.context7_api_key is not None
|
(settings.research_model, settings.research_variant),
|
||||||
else None
|
|
||||||
),
|
),
|
||||||
tools_bin=settings.dev_tools_dir / "bin",
|
timeout_seconds=settings.turn_timeout_seconds,
|
||||||
)
|
)
|
||||||
prompts = PromptLibrary()
|
prompts = PromptLibrary()
|
||||||
context = ContextBuilder(gitea, storage)
|
context = ContextBuilder(gitea, storage)
|
||||||
@@ -78,7 +82,7 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
storage=storage,
|
storage=storage,
|
||||||
gitea=gitea,
|
gitea=gitea,
|
||||||
git=git,
|
git=git,
|
||||||
codex=codex,
|
opencode=opencode,
|
||||||
prompts=prompts,
|
prompts=prompts,
|
||||||
context=context,
|
context=context,
|
||||||
development=development,
|
development=development,
|
||||||
@@ -86,11 +90,14 @@ async def build_container(settings: Settings) -> Container:
|
|||||||
dispatcher = Dispatcher(dependencies)
|
dispatcher = Dispatcher(dependencies)
|
||||||
worker = Worker(
|
worker = Worker(
|
||||||
storage=storage,
|
storage=storage,
|
||||||
|
state_machine=state_machine,
|
||||||
gitea=gitea,
|
gitea=gitea,
|
||||||
codex=codex,
|
opencode=opencode,
|
||||||
dispatcher=dispatcher,
|
dispatcher=dispatcher,
|
||||||
poll_seconds=settings.worker_poll_seconds,
|
poll_seconds=settings.worker_poll_seconds,
|
||||||
|
workspaces_dir=settings.workspaces_dir,
|
||||||
|
bot_username=settings.bot_username,
|
||||||
)
|
)
|
||||||
container = Container(settings, storage, gitea, git, codex, worker)
|
container = Container(settings, storage, gitea, git, opencode, state_machine, worker)
|
||||||
log.info("container initialization completed", extra={"operation": "container.build"})
|
log.info("container initialization completed", extra={"operation": "container.build"})
|
||||||
return container
|
return container
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from agentci.domain.models import Workflow
|
||||||
|
|
||||||
|
|
||||||
|
class Event(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
job_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class CommandReceived(Event):
|
||||||
|
type: Literal["command_received"] = "command_received"
|
||||||
|
delivery_id: str
|
||||||
|
receive_sequence: int
|
||||||
|
command_body: str
|
||||||
|
target_key: str
|
||||||
|
repo_owner: str
|
||||||
|
repo_name: str
|
||||||
|
issue_number: int
|
||||||
|
pr_number: int | None
|
||||||
|
requester: str
|
||||||
|
comment_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionGranted(Event):
|
||||||
|
type: Literal["permission_granted"] = "permission_granted"
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionDenied(Event):
|
||||||
|
type: Literal["permission_denied"] = "permission_denied"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStarted(Event):
|
||||||
|
type: Literal["job_started"] = "job_started"
|
||||||
|
|
||||||
|
|
||||||
|
class JobProgress(Event):
|
||||||
|
type: Literal["job_progress"] = "job_progress"
|
||||||
|
stage: str
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCreated(Event):
|
||||||
|
type: Literal["workflow_created"] = "workflow_created"
|
||||||
|
workflow: Workflow
|
||||||
|
stage: str
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowLinked(Event):
|
||||||
|
type: Literal["workflow_linked"] = "workflow_linked"
|
||||||
|
workflow_id: str
|
||||||
|
stage: str
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSessionLinked(Event):
|
||||||
|
type: Literal["runtime_session_linked"] = "runtime_session_linked"
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class JobCompleted(Event):
|
||||||
|
type: Literal["job_completed"] = "job_completed"
|
||||||
|
comment_body: str
|
||||||
|
|
||||||
|
|
||||||
|
class JobRejected(Event):
|
||||||
|
type: Literal["job_rejected"] = "job_rejected"
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class JobFailed(Event):
|
||||||
|
type: Literal["job_failed"] = "job_failed"
|
||||||
|
error: str
|
||||||
|
stage: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceRestarted(Event):
|
||||||
|
type: Literal["service_restarted"] = "service_restarted"
|
||||||
|
|
||||||
|
|
||||||
|
class CommentLinked(Event):
|
||||||
|
type: Literal["comment_linked"] = "comment_linked"
|
||||||
|
comment_id: int
|
||||||
|
|
||||||
|
|
||||||
|
JobEvent = Annotated[
|
||||||
|
CommandReceived
|
||||||
|
| PermissionGranted
|
||||||
|
| PermissionDenied
|
||||||
|
| JobStarted
|
||||||
|
| JobProgress
|
||||||
|
| WorkflowCreated
|
||||||
|
| WorkflowLinked
|
||||||
|
| RuntimeSessionLinked
|
||||||
|
| JobCompleted
|
||||||
|
| JobRejected
|
||||||
|
| JobFailed
|
||||||
|
| ServiceRestarted
|
||||||
|
| CommentLinked,
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
@@ -25,6 +25,7 @@ class JobKind(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class JobStatus(StrEnum):
|
class JobStatus(StrEnum):
|
||||||
|
RECEIVED = "received"
|
||||||
QUEUED = "queued"
|
QUEUED = "queued"
|
||||||
RUNNING = "running"
|
RUNNING = "running"
|
||||||
SUCCEEDED = "succeeded"
|
SUCCEEDED = "succeeded"
|
||||||
@@ -125,6 +126,7 @@ class Job:
|
|||||||
status: JobStatus = JobStatus.QUEUED
|
status: JobStatus = JobStatus.QUEUED
|
||||||
stage: str = "queued"
|
stage: str = "queued"
|
||||||
accepted_comment_id: int | None = None
|
accepted_comment_id: int | None = None
|
||||||
|
runtime_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -136,6 +138,7 @@ class Workflow:
|
|||||||
issue_number: int
|
issue_number: int
|
||||||
workspace_path: Path
|
workspace_path: Path
|
||||||
base_sha: str
|
base_sha: str
|
||||||
|
runtime: str = "opencode"
|
||||||
branch: str | None = None
|
branch: str | None = None
|
||||||
pr_number: int | None = None
|
pr_number: int | None = None
|
||||||
primary_session_id: str | None = None
|
primary_session_id: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
from agentci.domain.commands import CommandError, parse_command, resolve_job_kind
|
||||||
|
from agentci.domain.events import (
|
||||||
|
CommandReceived,
|
||||||
|
CommentLinked,
|
||||||
|
JobCompleted,
|
||||||
|
JobEvent,
|
||||||
|
JobFailed,
|
||||||
|
JobProgress,
|
||||||
|
JobRejected,
|
||||||
|
JobStarted,
|
||||||
|
PermissionDenied,
|
||||||
|
PermissionGranted,
|
||||||
|
RuntimeSessionLinked,
|
||||||
|
ServiceRestarted,
|
||||||
|
WorkflowCreated,
|
||||||
|
WorkflowLinked,
|
||||||
|
)
|
||||||
|
from agentci.domain.models import JobKind, JobStatus
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransition(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Notification:
|
||||||
|
listener: str
|
||||||
|
queue: str = "control"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JobState:
|
||||||
|
id: str
|
||||||
|
target_key: str
|
||||||
|
repo_owner: str
|
||||||
|
repo_name: str
|
||||||
|
issue_number: int
|
||||||
|
pr_number: int | None
|
||||||
|
requester: str
|
||||||
|
comment_id: int
|
||||||
|
delivery_id: str
|
||||||
|
receive_sequence: int
|
||||||
|
command_body: str
|
||||||
|
kind: JobKind | None = None
|
||||||
|
message: str | None = None
|
||||||
|
status: JobStatus = JobStatus.RECEIVED
|
||||||
|
stage: str = "received"
|
||||||
|
error: str | None = None
|
||||||
|
workflow_id: str | None = None
|
||||||
|
runtime_session_id: str | None = None
|
||||||
|
accepted_comment_id: int | None = None
|
||||||
|
comment_body: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pull_request(self) -> bool:
|
||||||
|
return self.pr_number is not None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Transition:
|
||||||
|
state: JobState
|
||||||
|
notifications: tuple[Notification, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
RECONCILE = Notification("reconcile_comment")
|
||||||
|
|
||||||
|
|
||||||
|
def next_state(state: JobState | None, event: JobEvent) -> Transition:
|
||||||
|
if state is None:
|
||||||
|
if not isinstance(event, CommandReceived):
|
||||||
|
raise InvalidTransition("Only CommandReceived can create a job")
|
||||||
|
created = JobState(
|
||||||
|
id=event.job_id,
|
||||||
|
target_key=event.target_key,
|
||||||
|
repo_owner=event.repo_owner,
|
||||||
|
repo_name=event.repo_name,
|
||||||
|
issue_number=event.issue_number,
|
||||||
|
pr_number=event.pr_number,
|
||||||
|
requester=event.requester,
|
||||||
|
comment_id=event.comment_id,
|
||||||
|
delivery_id=event.delivery_id,
|
||||||
|
receive_sequence=event.receive_sequence,
|
||||||
|
command_body=event.command_body,
|
||||||
|
)
|
||||||
|
return Transition(created, (Notification("authorize"),))
|
||||||
|
if event.job_id != state.id:
|
||||||
|
raise InvalidTransition("Event job ID does not match state")
|
||||||
|
if isinstance(event, CommentLinked):
|
||||||
|
return Transition(replace(state, accepted_comment_id=event.comment_id))
|
||||||
|
if isinstance(event, ServiceRestarted):
|
||||||
|
if state.status is not JobStatus.RUNNING:
|
||||||
|
return Transition(state)
|
||||||
|
failed = replace(
|
||||||
|
state,
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
stage="interrupted",
|
||||||
|
error="Service restarted during an active OpenCode turn",
|
||||||
|
)
|
||||||
|
listeners = [Notification("abort_sessions"), RECONCILE]
|
||||||
|
if state.workflow_id:
|
||||||
|
listeners.insert(1, Notification("fail_workflow"))
|
||||||
|
return Transition(failed, tuple(listeners))
|
||||||
|
if state.status is JobStatus.RECEIVED:
|
||||||
|
return _received(state, event)
|
||||||
|
if state.status is JobStatus.QUEUED and isinstance(event, JobStarted):
|
||||||
|
return Transition(
|
||||||
|
replace(state, status=JobStatus.RUNNING, stage="starting"), (RECONCILE,)
|
||||||
|
)
|
||||||
|
if state.status is JobStatus.RUNNING:
|
||||||
|
return _running(state, event)
|
||||||
|
raise InvalidTransition(f"{event.type} is invalid while job is {state.status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _received(state: JobState, event: JobEvent) -> Transition:
|
||||||
|
if isinstance(event, PermissionDenied):
|
||||||
|
reason = "Agent command rejected: repository write permission is required."
|
||||||
|
return Transition(
|
||||||
|
replace(state, status=JobStatus.REJECTED, stage="rejected", error=reason),
|
||||||
|
(RECONCILE,),
|
||||||
|
)
|
||||||
|
if not isinstance(event, PermissionGranted):
|
||||||
|
raise InvalidTransition(f"{event.type} is invalid while job is received")
|
||||||
|
try:
|
||||||
|
command = parse_command(state.command_body)
|
||||||
|
if command is None:
|
||||||
|
raise CommandError("Invalid agent command.")
|
||||||
|
kind = resolve_job_kind(command, is_pull_request=state.is_pull_request)
|
||||||
|
except CommandError as exc:
|
||||||
|
return Transition(
|
||||||
|
replace(state, status=JobStatus.REJECTED, stage="rejected", error=str(exc)),
|
||||||
|
(RECONCILE,),
|
||||||
|
)
|
||||||
|
queued = replace(
|
||||||
|
state,
|
||||||
|
kind=kind,
|
||||||
|
message=command.message,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
stage="queued",
|
||||||
|
)
|
||||||
|
return Transition(queued, (Notification("execute", "jobs"), RECONCILE))
|
||||||
|
|
||||||
|
|
||||||
|
def _running(state: JobState, event: JobEvent) -> Transition:
|
||||||
|
if isinstance(event, JobProgress):
|
||||||
|
return Transition(replace(state, stage=event.stage))
|
||||||
|
if isinstance(event, WorkflowCreated):
|
||||||
|
return Transition(replace(state, workflow_id=event.workflow.id, stage=event.stage))
|
||||||
|
if isinstance(event, WorkflowLinked):
|
||||||
|
return Transition(replace(state, workflow_id=event.workflow_id, stage=event.stage))
|
||||||
|
if isinstance(event, RuntimeSessionLinked):
|
||||||
|
return Transition(replace(state, runtime_session_id=event.session_id))
|
||||||
|
if isinstance(event, JobCompleted):
|
||||||
|
return Transition(
|
||||||
|
replace(
|
||||||
|
state,
|
||||||
|
status=JobStatus.SUCCEEDED,
|
||||||
|
stage="completed",
|
||||||
|
comment_body=event.comment_body,
|
||||||
|
),
|
||||||
|
(RECONCILE,),
|
||||||
|
)
|
||||||
|
if isinstance(event, (JobRejected, JobFailed)):
|
||||||
|
rejected = isinstance(event, JobRejected)
|
||||||
|
error = event.reason if rejected else event.error
|
||||||
|
stage = "rejected" if rejected else event.stage
|
||||||
|
status = JobStatus.REJECTED if rejected else JobStatus.FAILED
|
||||||
|
listeners = [RECONCILE]
|
||||||
|
if state.workflow_id:
|
||||||
|
listeners.append(Notification("fail_workflow"))
|
||||||
|
return Transition(replace(state, status=status, stage=stage, error=error), tuple(listeners))
|
||||||
|
raise InvalidTransition(f"{event.type} is invalid while job is running")
|
||||||
|
|
||||||
|
|
||||||
|
def render_job_comment(state: JobState) -> str:
|
||||||
|
marker = f"<!-- agentci:job id={state.id} -->"
|
||||||
|
if state.status is JobStatus.SUCCEEDED and state.comment_body:
|
||||||
|
body = state.comment_body
|
||||||
|
elif state.status is JobStatus.REJECTED:
|
||||||
|
body = f"Agent job `{state.id}` was rejected: {state.error}"
|
||||||
|
elif state.status is JobStatus.FAILED:
|
||||||
|
body = f"Agent job `{state.id}` failed during `{state.stage}`: {state.error}"
|
||||||
|
else:
|
||||||
|
kind = state.kind.value if state.kind else "command"
|
||||||
|
body = f"Agent job `{state.id}` {state.status.value} (`{kind}`; stage: `{state.stage}`)."
|
||||||
|
return f"{marker}\n{body}"
|
||||||
@@ -49,3 +49,5 @@ def configure_logging() -> None:
|
|||||||
root.handlers.clear()
|
root.handlers.clear()
|
||||||
root.addHandler(handler)
|
root.addHandler(handler)
|
||||||
root.setLevel(logging.INFO)
|
root.setLevel(logging.INFO)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE workflows ADD COLUMN runtime TEXT NOT NULL DEFAULT 'codex';
|
||||||
|
ALTER TABLE jobs ADD COLUMN runtime_session_id TEXT;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
|
||||||
|
ALTER TABLE jobs RENAME TO jobs_legacy;
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT,
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
repo_owner TEXT NOT NULL,
|
||||||
|
repo_name TEXT NOT NULL,
|
||||||
|
issue_number INTEGER NOT NULL,
|
||||||
|
pr_number INTEGER,
|
||||||
|
requester TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
comment_id INTEGER NOT NULL,
|
||||||
|
delivery_id TEXT NOT NULL UNIQUE,
|
||||||
|
receive_sequence INTEGER NOT NULL UNIQUE,
|
||||||
|
command_body TEXT NOT NULL,
|
||||||
|
workflow_id TEXT REFERENCES workflows(id),
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
runtime_session_id TEXT,
|
||||||
|
accepted_comment_id INTEGER,
|
||||||
|
started_comment_id INTEGER,
|
||||||
|
comment_body TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO jobs (
|
||||||
|
id, kind, target_key, repo_owner, repo_name, issue_number, pr_number,
|
||||||
|
requester, message, comment_id, delivery_id, receive_sequence, command_body,
|
||||||
|
workflow_id, status, stage, error, runtime_session_id, accepted_comment_id,
|
||||||
|
started_comment_id, created_at, started_at, finished_at
|
||||||
|
)
|
||||||
|
SELECT j.id, j.kind, j.target_key, j.repo_owner, j.repo_name, j.issue_number,
|
||||||
|
j.pr_number, j.requester, j.message, j.comment_id,
|
||||||
|
COALESCE(d.delivery_id, 'legacy:' || j.id),
|
||||||
|
ROW_NUMBER() OVER (ORDER BY j.created_at, j.id),
|
||||||
|
'/agent ' || CASE j.kind
|
||||||
|
WHEN 'iterate_plan' THEN 'iterate'
|
||||||
|
WHEN 'iterate_implement' THEN 'iterate'
|
||||||
|
ELSE j.kind END || CASE WHEN j.message = '' THEN '' ELSE ' ' || j.message END,
|
||||||
|
j.workflow_id, j.status, j.stage, j.error, j.runtime_session_id,
|
||||||
|
j.accepted_comment_id, j.started_comment_id, j.created_at, j.started_at, j.finished_at
|
||||||
|
FROM jobs_legacy j
|
||||||
|
LEFT JOIN deliveries d ON d.comment_id = j.comment_id;
|
||||||
|
|
||||||
|
CREATE TABLE job_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
job_id TEXT NOT NULL REFERENCES jobs(id) DEFERRABLE INITIALLY DEFERRED,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO job_events(event_id, job_id, event_type, payload_json, created_at)
|
||||||
|
SELECT 'delivery:' || delivery_id, id, 'legacy', '{}', created_at FROM jobs;
|
||||||
|
|
||||||
|
CREATE TABLE listener_tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id TEXT NOT NULL REFERENCES jobs(id),
|
||||||
|
source_event_id TEXT NOT NULL REFERENCES job_events(event_id),
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
listener TEXT NOT NULL,
|
||||||
|
queue TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
available_at TEXT NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
UNIQUE(source_event_id, listener, ordinal)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO listener_tasks(
|
||||||
|
job_id, source_event_id, ordinal, listener, queue, status, available_at, created_at
|
||||||
|
)
|
||||||
|
SELECT id, 'delivery:' || delivery_id, 0, 'execute', 'jobs', 'pending', created_at, created_at
|
||||||
|
FROM jobs WHERE status = 'queued';
|
||||||
|
|
||||||
|
INSERT INTO listener_tasks(
|
||||||
|
job_id, source_event_id, ordinal, listener, queue, status, available_at, created_at
|
||||||
|
)
|
||||||
|
SELECT id, 'delivery:' || delivery_id, 1, 'reconcile_comment', 'control', 'pending',
|
||||||
|
created_at, created_at
|
||||||
|
FROM jobs WHERE status = 'queued';
|
||||||
|
|
||||||
|
DROP TABLE jobs_legacy;
|
||||||
|
CREATE INDEX idx_jobs_queue ON jobs(status, receive_sequence);
|
||||||
|
CREATE INDEX idx_jobs_target ON jobs(target_key, status);
|
||||||
|
CREATE INDEX idx_listener_eligible ON listener_tasks(queue, status, available_at, id);
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
|
||||||
|
from agentci.domain.events import (
|
||||||
|
JobProgress,
|
||||||
|
RuntimeSessionLinked,
|
||||||
|
WorkflowCreated,
|
||||||
|
WorkflowLinked,
|
||||||
|
)
|
||||||
|
from agentci.domain.models import Workflow
|
||||||
|
from agentci.state_machine import StateMachine
|
||||||
|
|
||||||
|
|
||||||
|
class NullReporter:
|
||||||
|
final_body: str | None = None
|
||||||
|
|
||||||
|
async def progress(self, _stage: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def create_workflow(self, _workflow: Workflow, _stage: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def link_workflow(self, _workflow_id: str, _stage: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def link_runtime_session(self, _session_id: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def finish(self, body: str) -> None:
|
||||||
|
self.final_body = body
|
||||||
|
|
||||||
|
|
||||||
|
_current: ContextVar[JobReporter | NullReporter | None] = ContextVar(
|
||||||
|
"job_reporter", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobReporter:
|
||||||
|
def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.job_id = job_id
|
||||||
|
self.task_id = task_id
|
||||||
|
self.sequence = 0
|
||||||
|
self.final_body: str | None = None
|
||||||
|
|
||||||
|
async def progress(self, stage: str) -> None:
|
||||||
|
await self._emit(JobProgress(job_id=self.job_id, stage=stage))
|
||||||
|
|
||||||
|
async def create_workflow(self, workflow: Workflow, stage: str) -> None:
|
||||||
|
await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage))
|
||||||
|
|
||||||
|
async def link_workflow(self, workflow_id: str, stage: str) -> None:
|
||||||
|
await self._emit(
|
||||||
|
WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def link_runtime_session(self, session_id: str) -> None:
|
||||||
|
await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id))
|
||||||
|
|
||||||
|
def finish(self, body: str) -> None:
|
||||||
|
self.final_body = body
|
||||||
|
|
||||||
|
async def _emit(self, event) -> None:
|
||||||
|
self.sequence += 1
|
||||||
|
await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_reporter(
|
||||||
|
reporter: JobReporter,
|
||||||
|
) -> Token[JobReporter | NullReporter | None]:
|
||||||
|
return _current.set(reporter)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None:
|
||||||
|
_current.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def reporter() -> JobReporter | NullReporter:
|
||||||
|
return _current.get() or NullReporter()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID, uuid5
|
||||||
|
|
||||||
|
from agentci.adapters.job_store import EvolveResult, JobStore
|
||||||
|
from agentci.domain.events import JobEvent
|
||||||
|
from agentci.domain.models import CommandEvent
|
||||||
|
from agentci.domain.state_machine import JobState
|
||||||
|
|
||||||
|
JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReceiveResult:
|
||||||
|
state: JobState
|
||||||
|
duplicate: bool
|
||||||
|
|
||||||
|
|
||||||
|
class StateMachine:
|
||||||
|
def __init__(self, store: JobStore) -> None:
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
async def receive(self, incoming: CommandEvent) -> ReceiveResult:
|
||||||
|
job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id))
|
||||||
|
result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming)
|
||||||
|
return ReceiveResult(result.state, result.duplicate)
|
||||||
|
|
||||||
|
async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult:
|
||||||
|
return await self.store.evolve(event_id, event)
|
||||||
|
|
||||||
|
async def get(self, job_id: str) -> JobState | None:
|
||||||
|
return await self.store.get_job_state(job_id)
|
||||||
+168
-111
@@ -3,11 +3,28 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.adapters.codex import CodexClient
|
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.adapters.gitea import GiteaClient
|
||||||
|
from agentci.adapters.job_store import ListenerTask
|
||||||
|
from agentci.adapters.opencode import OpenCodeClient
|
||||||
from agentci.adapters.storage import Storage
|
from agentci.adapters.storage import Storage
|
||||||
from agentci.domain.models import Job, JobStatus
|
from agentci.domain.events import (
|
||||||
|
CommentLinked,
|
||||||
|
JobCompleted,
|
||||||
|
JobFailed,
|
||||||
|
JobStarted,
|
||||||
|
PermissionDenied,
|
||||||
|
PermissionGranted,
|
||||||
|
ServiceRestarted,
|
||||||
|
)
|
||||||
|
from agentci.domain.events import (
|
||||||
|
JobRejected as RejectedEvent,
|
||||||
|
)
|
||||||
|
from agentci.domain.models import JobStatus
|
||||||
|
from agentci.domain.state_machine import JobState, render_job_comment
|
||||||
|
from agentci.reporting import JobReporter, bind_reporter, reset_reporter
|
||||||
|
from agentci.state_machine import StateMachine
|
||||||
from agentci.workflows.common import JobRejected
|
from agentci.workflows.common import JobRejected
|
||||||
from agentci.workflows.dispatcher import Dispatcher
|
from agentci.workflows.dispatcher import Dispatcher
|
||||||
|
|
||||||
@@ -19,134 +36,174 @@ class Worker:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
|
state_machine: StateMachine,
|
||||||
gitea: GiteaClient,
|
gitea: GiteaClient,
|
||||||
codex: CodexClient,
|
opencode: OpenCodeClient,
|
||||||
dispatcher: Dispatcher,
|
dispatcher: Dispatcher,
|
||||||
poll_seconds: float,
|
poll_seconds: float,
|
||||||
|
workspaces_dir: Path,
|
||||||
|
bot_username: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
|
self.host = state_machine
|
||||||
self.gitea = gitea
|
self.gitea = gitea
|
||||||
self.codex = codex
|
self.opencode = opencode
|
||||||
self.dispatcher = dispatcher
|
self.dispatcher = dispatcher
|
||||||
self.poll_seconds = poll_seconds
|
self.poll_seconds = poll_seconds
|
||||||
|
self.workspaces_dir = workspaces_dir
|
||||||
|
self.bot_username = bot_username
|
||||||
|
|
||||||
async def run(self, stop: asyncio.Event) -> None:
|
async def run(self, stop: asyncio.Event) -> None:
|
||||||
log.info("worker started", extra={"operation": "worker.run"})
|
await self._recover()
|
||||||
await self._report_interrupted()
|
await asyncio.gather(self._loop("control", stop), self._loop("jobs", stop))
|
||||||
try:
|
|
||||||
while not stop.is_set():
|
|
||||||
if not await self.codex.login_ready():
|
|
||||||
log.warning(
|
|
||||||
"worker waiting for Codex authentication",
|
|
||||||
extra={"operation": "worker.poll"},
|
|
||||||
)
|
|
||||||
await self._wait(stop)
|
|
||||||
continue
|
|
||||||
job = await self.storage.claim_next()
|
|
||||||
if job is None:
|
|
||||||
await self._wait(stop)
|
|
||||||
continue
|
|
||||||
await self._run_job(job)
|
|
||||||
except Exception:
|
|
||||||
log.exception("worker stopped unexpectedly", extra={"operation": "worker.run"})
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
log.info("worker stopped", extra={"operation": "worker.run"})
|
|
||||||
|
|
||||||
async def _run_job(self, job: Job) -> None:
|
async def _loop(self, queue: str, stop: asyncio.Event) -> None:
|
||||||
extra = {"job_id": job.id, "target": job.target_key}
|
while not stop.is_set():
|
||||||
log.info("job started", extra=extra)
|
if queue == "jobs" and not await self.opencode.ready():
|
||||||
try:
|
await self._wait(stop)
|
||||||
if job.accepted_comment_id is None:
|
continue
|
||||||
accepted_id = await self.gitea.create_comment(
|
task = await self.storage.claim_task(queue)
|
||||||
job.repo_owner,
|
if task is None:
|
||||||
job.repo_name,
|
await self._wait(stop)
|
||||||
job.issue_number,
|
continue
|
||||||
f"Agent job `{job.id}` queued (`{job.kind}`).",
|
try:
|
||||||
|
await self._handle(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception(
|
||||||
|
"listener failed",
|
||||||
|
extra={"task_id": task.id, "listener": task.listener, "queue": queue},
|
||||||
)
|
)
|
||||||
await self.storage.set_job_comment(
|
await self.storage.retry_task(task.id, task.attempts, _safe_error(exc))
|
||||||
job.id, "accepted_comment_id", accepted_id
|
else:
|
||||||
)
|
await self.storage.complete_task(task.id)
|
||||||
comment_id = await self.gitea.create_comment(
|
|
||||||
job.repo_owner,
|
async def _handle(self, task: ListenerTask) -> None:
|
||||||
job.repo_name,
|
state = await self.host.get(task.job_id)
|
||||||
job.issue_number,
|
if state is None:
|
||||||
f"Agent job `{job.id}` started (`{job.kind}`).",
|
return
|
||||||
)
|
if task.listener == "authorize":
|
||||||
await self.storage.set_job_comment(job.id, "started_comment_id", comment_id)
|
await self._authorize(task, state)
|
||||||
await self.dispatcher.dispatch(job)
|
elif task.listener == "execute":
|
||||||
except JobRejected as exc:
|
await self._execute(task, state)
|
||||||
await self._safe_update_job(
|
elif task.listener == "reconcile_comment":
|
||||||
job, status=JobStatus.REJECTED, stage="rejected", error=str(exc)
|
await self._reconcile(task, state)
|
||||||
)
|
elif task.listener == "fail_workflow":
|
||||||
await self._safe_comment(job, f"Agent job `{job.id}` was rejected: {exc}")
|
await self.storage.fail_job_workflow(state.id)
|
||||||
log.info("job rejected", extra={**extra, "stage": "rejected"})
|
elif task.listener == "abort_sessions":
|
||||||
except Exception as exc:
|
await self._abort_job_sessions(state)
|
||||||
failed_stage = await self._safe_job_stage(job)
|
|
||||||
await self._safe_update_job(
|
|
||||||
job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc)
|
|
||||||
)
|
|
||||||
await self._safe_fail_workflow(job)
|
|
||||||
await self._safe_comment(
|
|
||||||
job,
|
|
||||||
f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}",
|
|
||||||
)
|
|
||||||
log.exception("job failed", extra={**extra, "stage": failed_stage})
|
|
||||||
else:
|
else:
|
||||||
await self._safe_update_job(
|
raise RuntimeError(f"Unknown listener {task.listener}")
|
||||||
job, status=JobStatus.SUCCEEDED, stage="completed"
|
|
||||||
)
|
|
||||||
log.info("job completed", extra=extra)
|
|
||||||
|
|
||||||
async def _report_interrupted(self) -> None:
|
async def _authorize(self, task: ListenerTask, state: JobState) -> None:
|
||||||
jobs = await self.storage.recover_running()
|
if state.status is not JobStatus.RECEIVED:
|
||||||
if jobs:
|
return
|
||||||
log.warning(
|
permitted = await self.gitea.has_write_permission(
|
||||||
"recovering interrupted jobs",
|
state.repo_owner, state.repo_name, state.requester
|
||||||
extra={"operation": "worker.recover", "item_count": len(jobs)},
|
)
|
||||||
)
|
event = (
|
||||||
for job in jobs:
|
PermissionGranted(job_id=state.id)
|
||||||
await self._safe_fail_workflow(job)
|
if permitted
|
||||||
await self._safe_comment(
|
else PermissionDenied(job_id=state.id)
|
||||||
job,
|
)
|
||||||
f"Agent job `{job.id}` failed because the service restarted during execution.",
|
outcome = "permission-granted" if permitted else "permission-denied"
|
||||||
)
|
await self.host.evolve(f"task:{task.id}:{outcome}", event)
|
||||||
|
|
||||||
async def _safe_comment(self, job: Job, body: str) -> None:
|
async def _execute(self, task: ListenerTask, state: JobState) -> None:
|
||||||
|
if state.status is JobStatus.RUNNING:
|
||||||
|
await self.host.evolve(
|
||||||
|
f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if state.status is not JobStatus.QUEUED:
|
||||||
|
return
|
||||||
|
result = await self.host.evolve(
|
||||||
|
f"task:{task.id}:started", JobStarted(job_id=state.id)
|
||||||
|
)
|
||||||
|
running = result.state
|
||||||
|
reporter = JobReporter(self.host, state.id, task.id)
|
||||||
|
token = bind_reporter(reporter)
|
||||||
try:
|
try:
|
||||||
await self.gitea.create_comment(
|
await self.dispatcher.dispatch(running)
|
||||||
job.repo_owner, job.repo_name, job.issue_number, body
|
except JobRejected as exc:
|
||||||
|
await self.host.evolve(
|
||||||
|
f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc))
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
log.exception("could not publish job status", extra={"job_id": job.id})
|
latest = await self.host.get(state.id)
|
||||||
|
stage = latest.stage if latest else running.stage
|
||||||
async def _safe_job_stage(self, job: Job) -> str:
|
await self.host.evolve(
|
||||||
try:
|
f"task:{task.id}:failed",
|
||||||
return await self.storage.job_stage(job.id)
|
JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage),
|
||||||
except Exception:
|
|
||||||
log.exception("could not read failed job stage", extra={"job_id": job.id})
|
|
||||||
return job.stage or "unknown"
|
|
||||||
|
|
||||||
async def _safe_update_job(
|
|
||||||
self,
|
|
||||||
job: Job,
|
|
||||||
*,
|
|
||||||
status: JobStatus,
|
|
||||||
stage: str,
|
|
||||||
error: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
await self.storage.update_job(
|
|
||||||
job.id, status=status, stage=stage, error=error
|
|
||||||
)
|
)
|
||||||
except Exception:
|
else:
|
||||||
log.exception("could not persist job status", extra={"job_id": job.id})
|
await self.host.evolve(
|
||||||
|
f"task:{task.id}:completed",
|
||||||
|
JobCompleted(
|
||||||
|
job_id=state.id,
|
||||||
|
comment_body=reporter.final_body or "Agent job completed.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reset_reporter(token)
|
||||||
|
|
||||||
async def _safe_fail_workflow(self, job: Job) -> None:
|
async def _reconcile(self, task: ListenerTask, state: JobState) -> None:
|
||||||
try:
|
latest = await self.host.get(state.id)
|
||||||
await self.storage.fail_job_workflow(job.id)
|
if latest is None:
|
||||||
except Exception:
|
return
|
||||||
log.exception("could not mark workflow failed", extra={"job_id": job.id})
|
body = render_job_comment(latest)
|
||||||
|
comment_id = latest.accepted_comment_id
|
||||||
|
if comment_id is not None and await self.gitea.update_comment(
|
||||||
|
latest.repo_owner, latest.repo_name, comment_id, body
|
||||||
|
):
|
||||||
|
return
|
||||||
|
marker = f"<!-- agentci:job id={latest.id} -->"
|
||||||
|
matches = sorted(
|
||||||
|
comment.id
|
||||||
|
for comment in await self.gitea.issue_comments(
|
||||||
|
latest.repo_owner, latest.repo_name, latest.issue_number
|
||||||
|
)
|
||||||
|
if comment.body.startswith(marker)
|
||||||
|
and comment.author.casefold() == self.bot_username.casefold()
|
||||||
|
)
|
||||||
|
if matches:
|
||||||
|
comment_id = matches[0]
|
||||||
|
else:
|
||||||
|
comment_id = await self.gitea.create_comment(
|
||||||
|
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
||||||
|
)
|
||||||
|
await self.host.evolve(
|
||||||
|
f"task:{task.id}:comment:{comment_id}",
|
||||||
|
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
||||||
|
)
|
||||||
|
await self.gitea.update_comment(
|
||||||
|
latest.repo_owner, latest.repo_name, comment_id, body
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _recover(self) -> None:
|
||||||
|
await self.storage.recover_tasks()
|
||||||
|
for state in await self.storage.running_job_states():
|
||||||
|
await self.host.evolve(
|
||||||
|
f"recovery:{state.id}:service-restarted",
|
||||||
|
ServiceRestarted(job_id=state.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _abort_job_sessions(self, state: JobState) -> None:
|
||||||
|
sessions: set[tuple[str, Path]] = set()
|
||||||
|
workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None
|
||||||
|
if workflow:
|
||||||
|
sessions.update(
|
||||||
|
(session, workflow.workspace_path)
|
||||||
|
for session in (workflow.primary_session_id, workflow.reviewer_session_id)
|
||||||
|
if session
|
||||||
|
)
|
||||||
|
elif state.runtime_session_id:
|
||||||
|
sessions.add(
|
||||||
|
(state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo")
|
||||||
|
)
|
||||||
|
for session, workspace in sessions:
|
||||||
|
await self.opencode.abort(session, workspace)
|
||||||
|
|
||||||
async def _wait(self, stop: asyncio.Event) -> None:
|
async def _wait(self, stop: asyncio.Event) -> None:
|
||||||
with suppress(TimeoutError):
|
with suppress(TimeoutError):
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
"""Codex workflow orchestration."""
|
"""OpenCode workflow orchestration."""
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from agentci.domain.models import AgentResult, Job
|
from agentci.domain.models import AgentResult, Job
|
||||||
|
from agentci.reporting import reporter
|
||||||
from agentci.workflows.common import Dependencies, JobRejected
|
from agentci.workflows.common import Dependencies, JobRejected
|
||||||
|
|
||||||
|
|
||||||
@@ -20,14 +21,14 @@ class ChangeSet:
|
|||||||
set_upstream: bool,
|
set_upstream: bool,
|
||||||
commit_prefix: str,
|
commit_prefix: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
await self.deps.storage.update_job(job.id, stage="validating changes")
|
await reporter().progress("validating changes")
|
||||||
if not await self.deps.git.has_changes(workspace):
|
if not await self.deps.git.has_changes(workspace):
|
||||||
raise JobRejected("Codex completed without producing any file changes.")
|
raise JobRejected("OpenCode completed without producing any file changes.")
|
||||||
await self.deps.git.diff_check(workspace)
|
await self.deps.git.diff_check(workspace)
|
||||||
title = _commit_title(result.summary_markdown)
|
title = _commit_title(result.summary_markdown)
|
||||||
await self.deps.storage.update_job(job.id, stage="committing changes")
|
await reporter().progress("committing changes")
|
||||||
sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}")
|
sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}")
|
||||||
await self.deps.storage.update_job(job.id, stage="pushing changes")
|
await reporter().progress("pushing changes")
|
||||||
await self.deps.git.push(workspace, branch, set_upstream=set_upstream)
|
await self.deps.git.push(workspace, branch, set_upstream=set_upstream)
|
||||||
return sha
|
return sha
|
||||||
|
|
||||||
@@ -54,4 +55,3 @@ def _commit_title(markdown: str) -> str:
|
|||||||
if value:
|
if value:
|
||||||
return value[:72]
|
return value[:72]
|
||||||
return "apply requested changes"
|
return "apply requested changes"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
|
from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow
|
||||||
from agentci.workflows.common import Dependencies, report_for_prompt, report_json
|
from agentci.reporting import reporter
|
||||||
|
from agentci.workflows.common import (
|
||||||
|
Dependencies,
|
||||||
|
report_for_prompt,
|
||||||
|
report_json,
|
||||||
|
required_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CodeReviewLoop:
|
class CodeReviewLoop:
|
||||||
@@ -18,10 +24,9 @@ class CodeReviewLoop:
|
|||||||
) -> tuple[AgentResult, ReviewReport]:
|
) -> tuple[AgentResult, ReviewReport]:
|
||||||
report = ReviewReport(summary="", findings=[])
|
report = ReviewReport(summary="", findings=[])
|
||||||
for round_index in range(self.deps.settings.implement_review_rounds):
|
for round_index in range(self.deps.settings.implement_review_rounds):
|
||||||
await self.deps.storage.update_job(
|
await reporter().progress(
|
||||||
job.id,
|
f"reviewing implementation {round_index + 1}/"
|
||||||
stage=f"reviewing implementation {round_index + 1}/"
|
f"{self.deps.settings.implement_review_rounds}"
|
||||||
f"{self.deps.settings.implement_review_rounds}",
|
|
||||||
)
|
)
|
||||||
report = await self.once(
|
report = await self.once(
|
||||||
workflow,
|
workflow,
|
||||||
@@ -44,12 +49,11 @@ class CodeReviewLoop:
|
|||||||
review=report_for_prompt(workflow.review_json),
|
review=report_for_prompt(workflow.review_json),
|
||||||
development_environment=self.deps.development.description,
|
development_environment=self.deps.development.description,
|
||||||
)
|
)
|
||||||
result = await self.deps.codex.resume(
|
result = await self.deps.opencode.resume(
|
||||||
session_id=_required(workflow.primary_session_id),
|
session_id=required_session(workflow.primary_session_id),
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=self.deps.settings.implement_model,
|
||||||
reasoning=self.deps.settings.implement_reasoning,
|
variant=self.deps.settings.implement_variant,
|
||||||
permission="agentci-write",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="agent_result.json",
|
schema_name="agent_result.json",
|
||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
@@ -71,30 +75,27 @@ class CodeReviewLoop:
|
|||||||
pull_context=pull_context,
|
pull_context=pull_context,
|
||||||
)
|
)
|
||||||
if workflow.reviewer_session_id:
|
if workflow.reviewer_session_id:
|
||||||
return await self.deps.codex.resume(
|
return await self.deps.opencode.resume(
|
||||||
session_id=workflow.reviewer_session_id,
|
session_id=workflow.reviewer_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=self.deps.settings.implement_model,
|
||||||
reasoning=self.deps.settings.implement_reasoning,
|
variant=self.deps.settings.implement_variant,
|
||||||
permission="agentci-review",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="review.json",
|
schema_name="review.json",
|
||||||
result_type=ReviewReport,
|
result_type=ReviewReport,
|
||||||
)
|
)
|
||||||
session_id, report = await self.deps.codex.start(
|
session_id = await self.deps.opencode.create_session(
|
||||||
|
workflow.workspace_path, "implementation-review"
|
||||||
|
)
|
||||||
|
workflow.reviewer_session_id = session_id
|
||||||
|
await self.deps.storage.update_workflow(workflow)
|
||||||
|
report = await self.deps.opencode.resume(
|
||||||
|
session_id=session_id,
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=self.deps.settings.implement_model,
|
||||||
reasoning=self.deps.settings.implement_reasoning,
|
variant=self.deps.settings.implement_variant,
|
||||||
permission="agentci-review",
|
|
||||||
schema_name="review.json",
|
schema_name="review.json",
|
||||||
result_type=ReviewReport,
|
result_type=ReviewReport,
|
||||||
)
|
)
|
||||||
workflow.reviewer_session_id = session_id
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
def _required(value: str | None) -> str:
|
|
||||||
if value is None:
|
|
||||||
raise RuntimeError("Expected a persisted Codex session ID")
|
|
||||||
return value
|
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from agentci.adapters.codex import CodexClient
|
|
||||||
from agentci.adapters.development import DevelopmentEnvironment
|
from agentci.adapters.development import DevelopmentEnvironment
|
||||||
from agentci.adapters.git import GitClient
|
from agentci.adapters.git import GitClient
|
||||||
from agentci.adapters.gitea import GiteaClient
|
from agentci.adapters.gitea import GiteaClient
|
||||||
|
from agentci.adapters.opencode import OpenCodeClient
|
||||||
from agentci.adapters.storage import Storage
|
from agentci.adapters.storage import Storage
|
||||||
from agentci.config import Settings
|
from agentci.config import Settings
|
||||||
from agentci.domain.models import ReviewReport
|
from agentci.domain.models import ReviewReport
|
||||||
from agentci.prompts import PromptLibrary
|
from agentci.prompts import PromptLibrary
|
||||||
|
from agentci.reporting import reporter
|
||||||
from agentci.workflows.context import ContextBuilder
|
from agentci.workflows.context import ContextBuilder
|
||||||
|
|
||||||
|
|
||||||
@@ -18,13 +19,19 @@ class JobRejected(RuntimeError):
|
|||||||
"""A safe, expected workflow rejection to publish to the requester."""
|
"""A safe, expected workflow rejection to publish to the requester."""
|
||||||
|
|
||||||
|
|
||||||
|
def required_session(value: str | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
raise RuntimeError("Expected a persisted OpenCode session ID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Dependencies:
|
class Dependencies:
|
||||||
settings: Settings
|
settings: Settings
|
||||||
storage: Storage
|
storage: Storage
|
||||||
gitea: GiteaClient
|
gitea: GiteaClient
|
||||||
git: GitClient
|
git: GitClient
|
||||||
codex: CodexClient
|
opencode: OpenCodeClient
|
||||||
prompts: PromptLibrary
|
prompts: PromptLibrary
|
||||||
context: ContextBuilder
|
context: ContextBuilder
|
||||||
development: DevelopmentEnvironment
|
development: DevelopmentEnvironment
|
||||||
@@ -63,3 +70,7 @@ def report_for_prompt(report_json_value: str | None) -> str:
|
|||||||
|
|
||||||
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
|
def agent_comment(kind: str, workflow_id: str, body: str) -> str:
|
||||||
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
|
return f"<!-- agentci:{kind} workflow={workflow_id} -->\n{body}"
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_job(body: str) -> None:
|
||||||
|
reporter().finish(body)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from agentci.domain.models import Job, JobKind
|
from agentci.domain.models import JobKind
|
||||||
|
from agentci.domain.state_machine import JobState
|
||||||
from agentci.workflows.common import Dependencies
|
from agentci.workflows.common import Dependencies
|
||||||
from agentci.workflows.implement import ImplementWorkflow
|
from agentci.workflows.implement import ImplementWorkflow
|
||||||
from agentci.workflows.plan import PlanWorkflow
|
from agentci.workflows.plan import PlanWorkflow
|
||||||
@@ -15,7 +17,7 @@ class Dispatcher:
|
|||||||
def __init__(self, dependencies: Dependencies) -> None:
|
def __init__(self, dependencies: Dependencies) -> None:
|
||||||
plan = PlanWorkflow(dependencies)
|
plan = PlanWorkflow(dependencies)
|
||||||
pull_request = PullRequestWorkflow(dependencies)
|
pull_request = PullRequestWorkflow(dependencies)
|
||||||
self.handlers = {
|
self.handlers: dict[JobKind, Any] = {
|
||||||
JobKind.PLAN: plan.plan,
|
JobKind.PLAN: plan.plan,
|
||||||
JobKind.DISCUSS: plan.discuss,
|
JobKind.DISCUSS: plan.discuss,
|
||||||
JobKind.ITERATE_PLAN: plan.iterate,
|
JobKind.ITERATE_PLAN: plan.iterate,
|
||||||
@@ -24,7 +26,9 @@ class Dispatcher:
|
|||||||
JobKind.FIX: pull_request.fix,
|
JobKind.FIX: pull_request.fix,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def dispatch(self, job: Job) -> None:
|
async def dispatch(self, job: JobState) -> None:
|
||||||
|
if job.kind is None:
|
||||||
|
raise RuntimeError("Cannot dispatch an unparsed command")
|
||||||
extra = {
|
extra = {
|
||||||
"operation": "workflow.dispatch",
|
"operation": "workflow.dispatch",
|
||||||
"job_id": job.id,
|
"job_id": job.id,
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ from agentci.domain.models import (
|
|||||||
WorkflowKind,
|
WorkflowKind,
|
||||||
WorkflowStatus,
|
WorkflowStatus,
|
||||||
)
|
)
|
||||||
|
from agentci.reporting import reporter
|
||||||
from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment
|
from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment
|
||||||
from agentci.workflows.code_review import CodeReviewLoop
|
from agentci.workflows.code_review import CodeReviewLoop
|
||||||
from agentci.workflows.common import (
|
from agentci.workflows.common import (
|
||||||
Dependencies,
|
Dependencies,
|
||||||
JobRejected,
|
JobRejected,
|
||||||
agent_comment,
|
agent_comment,
|
||||||
|
finish_job,
|
||||||
report_json,
|
report_json,
|
||||||
review_markdown,
|
review_markdown,
|
||||||
)
|
)
|
||||||
@@ -36,7 +38,7 @@ class ImplementWorkflow:
|
|||||||
f"{workflow_id[:8]}"
|
f"{workflow_id[:8]}"
|
||||||
)
|
)
|
||||||
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
|
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
|
||||||
await self.deps.storage.update_job(job.id, stage="cloning")
|
await reporter().progress("cloning")
|
||||||
base_sha = await self.deps.git.clone(
|
base_sha = await self.deps.git.clone(
|
||||||
job.repo_owner,
|
job.repo_owner,
|
||||||
job.repo_name,
|
job.repo_name,
|
||||||
@@ -54,15 +56,9 @@ class ImplementWorkflow:
|
|||||||
base_sha=base_sha,
|
base_sha=base_sha,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
)
|
)
|
||||||
await self.deps.storage.create_workflow(workflow)
|
await reporter().create_workflow(workflow, "installing development environment")
|
||||||
job.workflow_id = workflow.id
|
|
||||||
await self.deps.storage.update_job(
|
|
||||||
job.id, workflow_id=workflow.id, stage="installing development environment"
|
|
||||||
)
|
|
||||||
await self.deps.development.prepare(workspace)
|
await self.deps.development.prepare(workspace)
|
||||||
await self.deps.storage.update_job(
|
await reporter().progress("implementing")
|
||||||
job.id, workflow_id=workflow.id, stage="implementing"
|
|
||||||
)
|
|
||||||
context = await self.deps.context.issue_context(
|
context = await self.deps.context.issue_context(
|
||||||
job.repo_owner, job.repo_name, job.issue_number
|
job.repo_owner, job.repo_name, job.issue_number
|
||||||
)
|
)
|
||||||
@@ -76,16 +72,19 @@ class ImplementWorkflow:
|
|||||||
request=job.message or "(no additional request)",
|
request=job.message or "(no additional request)",
|
||||||
development_environment=self.deps.development.description,
|
development_environment=self.deps.development.description,
|
||||||
)
|
)
|
||||||
session_id, result = await self.deps.codex.start(
|
session_id = await self.deps.opencode.create_session(workspace, "implementation")
|
||||||
|
workflow.primary_session_id = session_id
|
||||||
|
await self.deps.storage.update_workflow(workflow)
|
||||||
|
await reporter().link_runtime_session(session_id)
|
||||||
|
result = await self.deps.opencode.resume(
|
||||||
|
session_id=session_id,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=self.deps.settings.implement_model,
|
||||||
reasoning=self.deps.settings.implement_reasoning,
|
variant=self.deps.settings.implement_variant,
|
||||||
permission="agentci-write",
|
|
||||||
schema_name="agent_result.json",
|
schema_name="agent_result.json",
|
||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
)
|
)
|
||||||
workflow.primary_session_id = session_id
|
|
||||||
workflow.artifact = result.model_dump_json()
|
workflow.artifact = result.model_dump_json()
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await self.deps.storage.update_workflow(workflow)
|
||||||
result, report = await self.review.run(
|
result, report = await self.review.run(
|
||||||
@@ -103,7 +102,7 @@ class ImplementWorkflow:
|
|||||||
set_upstream=True,
|
set_upstream=True,
|
||||||
commit_prefix="agent",
|
commit_prefix="agent",
|
||||||
)
|
)
|
||||||
await self.deps.storage.update_job(job.id, stage="creating pull request")
|
await reporter().progress("creating pull request")
|
||||||
pull = await self.deps.gitea.create_pull_request(
|
pull = await self.deps.gitea.create_pull_request(
|
||||||
job.repo_owner,
|
job.repo_owner,
|
||||||
job.repo_name,
|
job.repo_name,
|
||||||
@@ -122,17 +121,11 @@ class ImplementWorkflow:
|
|||||||
f"{job.repo_name}/pulls/{pull.number}"
|
f"{job.repo_name}/pulls/{pull.number}"
|
||||||
)
|
)
|
||||||
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
|
body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}"
|
||||||
await self.deps.gitea.create_comment(
|
body = agent_comment("implementation", workflow.id, body)
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
agent_comment("implementation", workflow.id, body),
|
|
||||||
)
|
|
||||||
remaining = review_markdown(report)
|
remaining = review_markdown(report)
|
||||||
if remaining:
|
if remaining:
|
||||||
await self.deps.gitea.create_comment(
|
body = f"{body}\n\n{remaining}"
|
||||||
job.repo_owner, job.repo_name, pull.number, remaining
|
await finish_job(body)
|
||||||
)
|
|
||||||
|
|
||||||
async def _reject_duplicate(self, job: Job) -> None:
|
async def _reject_duplicate(self, job: Job) -> None:
|
||||||
workflows = await self.deps.storage.implementation_workflows(
|
workflows = await self.deps.storage.implementation_workflows(
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ from agentci.domain.models import (
|
|||||||
WorkflowKind,
|
WorkflowKind,
|
||||||
WorkflowStatus,
|
WorkflowStatus,
|
||||||
)
|
)
|
||||||
|
from agentci.reporting import reporter
|
||||||
from agentci.workflows.common import (
|
from agentci.workflows.common import (
|
||||||
Dependencies,
|
Dependencies,
|
||||||
JobRejected,
|
JobRejected,
|
||||||
agent_comment,
|
agent_comment,
|
||||||
|
finish_job,
|
||||||
report_for_prompt,
|
report_for_prompt,
|
||||||
report_json,
|
report_json,
|
||||||
|
required_session,
|
||||||
review_markdown,
|
review_markdown,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ class PlanWorkflow:
|
|||||||
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
|
repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name)
|
||||||
workflow_id = str(uuid4())
|
workflow_id = str(uuid4())
|
||||||
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
|
workspace = self.deps.settings.workspaces_dir / workflow_id / "repo"
|
||||||
await self.deps.storage.update_job(job.id, stage="cloning")
|
await reporter().progress("cloning")
|
||||||
base_sha = await self.deps.git.clone(
|
base_sha = await self.deps.git.clone(
|
||||||
job.repo_owner,
|
job.repo_owner,
|
||||||
job.repo_name,
|
job.repo_name,
|
||||||
@@ -45,9 +48,7 @@ class PlanWorkflow:
|
|||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
base_sha=base_sha,
|
base_sha=base_sha,
|
||||||
)
|
)
|
||||||
await self.deps.storage.create_workflow(workflow)
|
await reporter().create_workflow(workflow, "planning")
|
||||||
job.workflow_id = workflow.id
|
|
||||||
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="planning")
|
|
||||||
context = await self.deps.context.issue_context(
|
context = await self.deps.context.issue_context(
|
||||||
job.repo_owner, job.repo_name, job.issue_number
|
job.repo_owner, job.repo_name, job.issue_number
|
||||||
)
|
)
|
||||||
@@ -56,16 +57,19 @@ class PlanWorkflow:
|
|||||||
context=context,
|
context=context,
|
||||||
request=job.message or "(no additional request)",
|
request=job.message or "(no additional request)",
|
||||||
)
|
)
|
||||||
session_id, artifact = await self.deps.codex.start(
|
session_id = await self.deps.opencode.create_session(workspace, "plan")
|
||||||
|
workflow.primary_session_id = session_id
|
||||||
|
await self.deps.storage.update_workflow(workflow)
|
||||||
|
await reporter().link_runtime_session(session_id)
|
||||||
|
artifact = await self.deps.opencode.resume(
|
||||||
|
session_id=session_id,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=self.deps.settings.plan_model,
|
||||||
reasoning=self.deps.settings.plan_reasoning,
|
variant=self.deps.settings.plan_variant,
|
||||||
permission="agentci-read",
|
|
||||||
schema_name="plan.json",
|
schema_name="plan.json",
|
||||||
result_type=PlanArtifact,
|
result_type=PlanArtifact,
|
||||||
)
|
)
|
||||||
workflow.primary_session_id = session_id
|
|
||||||
workflow.artifact = artifact.plan_markdown
|
workflow.artifact = artifact.plan_markdown
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await self.deps.storage.update_workflow(workflow)
|
||||||
report = await self._review_loop(job, workflow, context, artifact)
|
report = await self._review_loop(job, workflow, context, artifact)
|
||||||
@@ -73,39 +77,38 @@ class PlanWorkflow:
|
|||||||
|
|
||||||
async def discuss(self, job: Job) -> None:
|
async def discuss(self, job: Job) -> None:
|
||||||
workflow = await self._latest_plan(job)
|
workflow = await self._latest_plan(job)
|
||||||
|
if workflow.runtime != "opencode":
|
||||||
|
raise JobRejected(
|
||||||
|
"The latest plan predates OpenCode and cannot be resumed; "
|
||||||
|
"start a new `/agent plan`."
|
||||||
|
)
|
||||||
if not workflow.primary_session_id or not workflow.artifact:
|
if not workflow.primary_session_id or not workflow.artifact:
|
||||||
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
|
raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.")
|
||||||
await self.deps.storage.update_job(job.id, workflow_id=workflow.id, stage="discussing")
|
await reporter().link_workflow(workflow.id, "discussing")
|
||||||
prompt = self.deps.prompts.render(
|
prompt = self.deps.prompts.render(
|
||||||
"discuss", artifact=workflow.artifact, message=job.message
|
"discuss", artifact=workflow.artifact, message=job.message
|
||||||
)
|
)
|
||||||
reply = await self.deps.codex.resume(
|
reply = await self.deps.opencode.resume(
|
||||||
session_id=workflow.primary_session_id,
|
session_id=workflow.primary_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=self.deps.settings.plan_model,
|
||||||
reasoning=self.deps.settings.plan_reasoning,
|
variant=self.deps.settings.plan_variant,
|
||||||
permission="agentci-read",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="discussion.json",
|
schema_name="discussion.json",
|
||||||
result_type=DiscussionReply,
|
result_type=DiscussionReply,
|
||||||
)
|
)
|
||||||
await self.deps.gitea.create_comment(
|
await finish_job(agent_comment("discussion", workflow.id, reply.markdown))
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
agent_comment("discussion", workflow.id, reply.markdown),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def iterate(self, job: Job) -> None:
|
async def iterate(self, job: Job) -> None:
|
||||||
await self._reject_if_active_or_merged_pr(job)
|
await self._reject_if_active_or_merged_pr(job)
|
||||||
workflow = await self._latest_plan(job)
|
workflow = await self._latest_plan(job)
|
||||||
|
if workflow.runtime != "opencode":
|
||||||
|
raise JobRejected("The latest plan predates OpenCode; start a new plan.")
|
||||||
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
||||||
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
|
raise JobRejected("The latest plan is missing resumable sessions; start a new plan.")
|
||||||
if not workflow.artifact:
|
if not workflow.artifact:
|
||||||
raise JobRejected("The latest plan has no saved artifact.")
|
raise JobRejected("The latest plan has no saved artifact.")
|
||||||
await self.deps.storage.update_job(
|
await reporter().link_workflow(workflow.id, "iterating plan")
|
||||||
job.id, workflow_id=workflow.id, stage="iterating plan"
|
|
||||||
)
|
|
||||||
context = await self.deps.context.issue_context(
|
context = await self.deps.context.issue_context(
|
||||||
job.repo_owner, job.repo_name, job.issue_number
|
job.repo_owner, job.repo_name, job.issue_number
|
||||||
)
|
)
|
||||||
@@ -116,12 +119,11 @@ class PlanWorkflow:
|
|||||||
review=report_for_prompt(workflow.review_json),
|
review=report_for_prompt(workflow.review_json),
|
||||||
message=job.message or "(refine using the latest discussion and prior review)",
|
message=job.message or "(refine using the latest discussion and prior review)",
|
||||||
)
|
)
|
||||||
artifact = await self.deps.codex.resume(
|
artifact = await self.deps.opencode.resume(
|
||||||
session_id=workflow.primary_session_id,
|
session_id=workflow.primary_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=self.deps.settings.plan_model,
|
||||||
reasoning=self.deps.settings.plan_reasoning,
|
variant=self.deps.settings.plan_variant,
|
||||||
permission="agentci-read",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="plan.json",
|
schema_name="plan.json",
|
||||||
result_type=PlanArtifact,
|
result_type=PlanArtifact,
|
||||||
@@ -134,10 +136,8 @@ class PlanWorkflow:
|
|||||||
) -> ReviewReport:
|
) -> ReviewReport:
|
||||||
report = ReviewReport(summary="", findings=[])
|
report = ReviewReport(summary="", findings=[])
|
||||||
for round_index in range(self.deps.settings.plan_review_rounds):
|
for round_index in range(self.deps.settings.plan_review_rounds):
|
||||||
await self.deps.storage.update_job(
|
await reporter().progress(
|
||||||
job.id,
|
f"reviewing plan {round_index + 1}/{self.deps.settings.plan_review_rounds}"
|
||||||
stage=f"reviewing plan {round_index + 1}/"
|
|
||||||
f"{self.deps.settings.plan_review_rounds}",
|
|
||||||
)
|
)
|
||||||
report = await self._review(workflow, context, artifact)
|
report = await self._review(workflow, context, artifact)
|
||||||
workflow.artifact = artifact.plan_markdown
|
workflow.artifact = artifact.plan_markdown
|
||||||
@@ -152,12 +152,11 @@ class PlanWorkflow:
|
|||||||
artifact=artifact.plan_markdown,
|
artifact=artifact.plan_markdown,
|
||||||
review=report_for_prompt(workflow.review_json),
|
review=report_for_prompt(workflow.review_json),
|
||||||
)
|
)
|
||||||
artifact = await self.deps.codex.resume(
|
artifact = await self.deps.opencode.resume(
|
||||||
session_id=_required(workflow.primary_session_id),
|
session_id=required_session(workflow.primary_session_id),
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=self.deps.settings.plan_model,
|
||||||
reasoning=self.deps.settings.plan_reasoning,
|
variant=self.deps.settings.plan_variant,
|
||||||
permission="agentci-read",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="plan.json",
|
schema_name="plan.json",
|
||||||
result_type=PlanArtifact,
|
result_type=PlanArtifact,
|
||||||
@@ -171,26 +170,27 @@ class PlanWorkflow:
|
|||||||
"plan_review", context=context, artifact=artifact.plan_markdown
|
"plan_review", context=context, artifact=artifact.plan_markdown
|
||||||
)
|
)
|
||||||
if workflow.reviewer_session_id:
|
if workflow.reviewer_session_id:
|
||||||
return await self.deps.codex.resume(
|
return await self.deps.opencode.resume(
|
||||||
session_id=workflow.reviewer_session_id,
|
session_id=workflow.reviewer_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=self.deps.settings.plan_model,
|
||||||
reasoning=self.deps.settings.plan_reasoning,
|
variant=self.deps.settings.plan_variant,
|
||||||
permission="agentci-review",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="review.json",
|
schema_name="review.json",
|
||||||
result_type=ReviewReport,
|
result_type=ReviewReport,
|
||||||
)
|
)
|
||||||
session_id, report = await self.deps.codex.start(
|
session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review")
|
||||||
|
workflow.reviewer_session_id = session_id
|
||||||
|
await self.deps.storage.update_workflow(workflow)
|
||||||
|
report = await self.deps.opencode.resume(
|
||||||
|
session_id=session_id,
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.plan_model,
|
model=self.deps.settings.plan_model,
|
||||||
reasoning=self.deps.settings.plan_reasoning,
|
variant=self.deps.settings.plan_variant,
|
||||||
permission="agentci-review",
|
|
||||||
schema_name="review.json",
|
schema_name="review.json",
|
||||||
result_type=ReviewReport,
|
result_type=ReviewReport,
|
||||||
)
|
)
|
||||||
workflow.reviewer_session_id = session_id
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
async def _finish(
|
async def _finish(
|
||||||
@@ -204,17 +204,11 @@ class PlanWorkflow:
|
|||||||
workflow.review_json = report_json(report)
|
workflow.review_json = report_json(report)
|
||||||
workflow.status = WorkflowStatus.COMPLETED
|
workflow.status = WorkflowStatus.COMPLETED
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await self.deps.storage.update_workflow(workflow)
|
||||||
await self.deps.gitea.create_comment(
|
body = agent_comment("plan", workflow.id, artifact.plan_markdown)
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
job.issue_number,
|
|
||||||
agent_comment("plan", workflow.id, artifact.plan_markdown),
|
|
||||||
)
|
|
||||||
remaining = review_markdown(report)
|
remaining = review_markdown(report)
|
||||||
if remaining:
|
if remaining:
|
||||||
await self.deps.gitea.create_comment(
|
body = f"{body}\n\n{remaining}"
|
||||||
job.repo_owner, job.repo_name, job.issue_number, remaining
|
await finish_job(body)
|
||||||
)
|
|
||||||
|
|
||||||
async def _latest_plan(self, job: Job) -> Workflow:
|
async def _latest_plan(self, job: Job) -> Workflow:
|
||||||
workflow = await self.deps.storage.latest_workflow(
|
workflow = await self.deps.storage.latest_workflow(
|
||||||
@@ -239,9 +233,3 @@ class PlanWorkflow:
|
|||||||
f"Issue plan iteration is disabled because agent PR #{pull.number} "
|
f"Issue plan iteration is disabled because agent PR #{pull.number} "
|
||||||
"is open or merged. Iterate an open implementation on its PR."
|
"is open or merged. Iterate an open implementation on its PR."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _required(value: str | None) -> str:
|
|
||||||
if value is None: # Defensive: workflows with missing sessions are rejected earlier.
|
|
||||||
raise RuntimeError("Expected a persisted Codex session ID")
|
|
||||||
return value
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus
|
from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus
|
||||||
|
from agentci.reporting import reporter
|
||||||
from agentci.workflows.change_set import ChangeSet, result_comment
|
from agentci.workflows.change_set import ChangeSet, result_comment
|
||||||
from agentci.workflows.code_review import CodeReviewLoop
|
from agentci.workflows.code_review import CodeReviewLoop
|
||||||
from agentci.workflows.common import (
|
from agentci.workflows.common import (
|
||||||
Dependencies,
|
Dependencies,
|
||||||
JobRejected,
|
JobRejected,
|
||||||
agent_comment,
|
agent_comment,
|
||||||
|
finish_job,
|
||||||
report_for_prompt,
|
report_for_prompt,
|
||||||
report_json,
|
report_json,
|
||||||
review_markdown,
|
review_markdown,
|
||||||
@@ -28,6 +30,8 @@ class PullRequestWorkflow:
|
|||||||
raise JobRejected(
|
raise JobRejected(
|
||||||
"This is not an open agent-created implementation PR. Use `/agent fix`."
|
"This is not an open agent-created implementation PR. Use `/agent fix`."
|
||||||
)
|
)
|
||||||
|
if workflow.runtime != "opencode":
|
||||||
|
raise JobRejected("The implementation predates OpenCode and cannot be resumed.")
|
||||||
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
if not workflow.primary_session_id or not workflow.reviewer_session_id:
|
||||||
raise JobRejected("The implementation sessions cannot be resumed.")
|
raise JobRejected("The implementation sessions cannot be resumed.")
|
||||||
pull, context = await self.deps.context.pull_request_context(
|
pull, context = await self.deps.context.pull_request_context(
|
||||||
@@ -37,16 +41,11 @@ class PullRequestWorkflow:
|
|||||||
raise JobRejected("Implementation iteration requires an open pull request.")
|
raise JobRejected("Implementation iteration requires an open pull request.")
|
||||||
if workflow.branch != pull.head_branch:
|
if workflow.branch != pull.head_branch:
|
||||||
raise JobRejected("The pull request head branch no longer matches its workflow.")
|
raise JobRejected("The pull request head branch no longer matches its workflow.")
|
||||||
await self.deps.storage.update_job(
|
await reporter().link_workflow(workflow.id, "synchronizing branch")
|
||||||
job.id, workflow_id=workflow.id, stage="synchronizing branch"
|
|
||||||
)
|
|
||||||
job.workflow_id = workflow.id
|
|
||||||
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch)
|
await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch)
|
||||||
await self.deps.storage.update_job(
|
await reporter().progress("installing development environment")
|
||||||
job.id, stage="installing development environment"
|
|
||||||
)
|
|
||||||
await self.deps.development.prepare(workflow.workspace_path)
|
await self.deps.development.prepare(workflow.workspace_path)
|
||||||
await self.deps.storage.update_job(job.id, stage="implementing iteration")
|
await reporter().progress("implementing iteration")
|
||||||
prompt = self.deps.prompts.render(
|
prompt = self.deps.prompts.render(
|
||||||
"implementation_iterate",
|
"implementation_iterate",
|
||||||
context=context,
|
context=context,
|
||||||
@@ -54,12 +53,11 @@ class PullRequestWorkflow:
|
|||||||
message=job.message or "(perform one additional reviewed refinement)",
|
message=job.message or "(perform one additional reviewed refinement)",
|
||||||
development_environment=self.deps.development.description,
|
development_environment=self.deps.development.description,
|
||||||
)
|
)
|
||||||
result = await self.deps.codex.resume(
|
result = await self.deps.opencode.resume(
|
||||||
session_id=workflow.primary_session_id,
|
session_id=workflow.primary_session_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=self.deps.settings.implement_model,
|
||||||
reasoning=self.deps.settings.implement_reasoning,
|
variant=self.deps.settings.implement_variant,
|
||||||
permission="agentci-write",
|
|
||||||
workspace=workflow.workspace_path,
|
workspace=workflow.workspace_path,
|
||||||
schema_name="agent_result.json",
|
schema_name="agent_result.json",
|
||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
@@ -87,17 +85,13 @@ class PullRequestWorkflow:
|
|||||||
workflow.artifact = result.model_dump_json()
|
workflow.artifact = result.model_dump_json()
|
||||||
workflow.review_json = report_json(report)
|
workflow.review_json = report_json(report)
|
||||||
await self.deps.storage.update_workflow(workflow)
|
await self.deps.storage.update_workflow(workflow)
|
||||||
await self.deps.gitea.create_comment(
|
body = agent_comment(
|
||||||
job.repo_owner,
|
"iteration", workflow.id, result_comment(result, sha=sha)
|
||||||
job.repo_name,
|
|
||||||
pull_number,
|
|
||||||
agent_comment("iteration", workflow.id, result_comment(result, sha=sha)),
|
|
||||||
)
|
)
|
||||||
remaining = review_markdown(report)
|
remaining = review_markdown(report)
|
||||||
if remaining:
|
if remaining:
|
||||||
await self.deps.gitea.create_comment(
|
body = f"{body}\n\n{remaining}"
|
||||||
job.repo_owner, job.repo_name, pull_number, remaining
|
await finish_job(body)
|
||||||
)
|
|
||||||
|
|
||||||
async def fix(self, job: Job) -> None:
|
async def fix(self, job: Job) -> None:
|
||||||
pull_number = _pull_number(job)
|
pull_number = _pull_number(job)
|
||||||
@@ -107,16 +101,14 @@ class PullRequestWorkflow:
|
|||||||
if not pull.is_open:
|
if not pull.is_open:
|
||||||
raise JobRejected("Fixes require an open pull request.")
|
raise JobRejected("Fixes require an open pull request.")
|
||||||
workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo"
|
workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo"
|
||||||
await self.deps.storage.update_job(job.id, stage="cloning pull request")
|
await reporter().progress("cloning pull request")
|
||||||
await self.deps.git.clone(
|
await self.deps.git.clone(
|
||||||
pull.head_owner,
|
pull.head_owner,
|
||||||
pull.head_repo,
|
pull.head_repo,
|
||||||
pull.head_branch,
|
pull.head_branch,
|
||||||
workspace,
|
workspace,
|
||||||
)
|
)
|
||||||
await self.deps.storage.update_job(
|
await reporter().progress("installing development environment")
|
||||||
job.id, stage="installing development environment"
|
|
||||||
)
|
|
||||||
await self.deps.development.prepare(workspace)
|
await self.deps.development.prepare(workspace)
|
||||||
prompt = self.deps.prompts.render(
|
prompt = self.deps.prompts.render(
|
||||||
"fix",
|
"fix",
|
||||||
@@ -124,13 +116,15 @@ class PullRequestWorkflow:
|
|||||||
message=job.message or "(address the pull request feedback)",
|
message=job.message or "(address the pull request feedback)",
|
||||||
development_environment=self.deps.development.description,
|
development_environment=self.deps.development.description,
|
||||||
)
|
)
|
||||||
await self.deps.storage.update_job(job.id, stage="fixing")
|
await reporter().progress("fixing")
|
||||||
_, result = await self.deps.codex.start(
|
session_id = await self.deps.opencode.create_session(workspace, "fix")
|
||||||
|
await reporter().link_runtime_session(session_id)
|
||||||
|
result = await self.deps.opencode.resume(
|
||||||
|
session_id=session_id,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=self.deps.settings.implement_model,
|
model=self.deps.settings.implement_model,
|
||||||
reasoning=self.deps.settings.implement_reasoning,
|
variant=self.deps.settings.implement_variant,
|
||||||
permission="agentci-write",
|
|
||||||
schema_name="agent_result.json",
|
schema_name="agent_result.json",
|
||||||
result_type=AgentResult,
|
result_type=AgentResult,
|
||||||
)
|
)
|
||||||
@@ -142,12 +136,7 @@ class PullRequestWorkflow:
|
|||||||
set_upstream=False,
|
set_upstream=False,
|
||||||
commit_prefix="agent fix",
|
commit_prefix="agent fix",
|
||||||
)
|
)
|
||||||
await self.deps.gitea.create_comment(
|
await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha)))
|
||||||
job.repo_owner,
|
|
||||||
job.repo_name,
|
|
||||||
pull_number,
|
|
||||||
agent_comment("fix", job.id, result_comment(result, sha=sha)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pull_number(job: Job) -> int:
|
def _pull_number(job: Job) -> int:
|
||||||
|
|||||||
+13
-14
@@ -28,15 +28,14 @@ def serious_report() -> ReviewReport:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeCodex:
|
class FakeOpenCode:
|
||||||
def __init__(self, reports: list[ReviewReport]) -> None:
|
def __init__(self, reports: list[ReviewReport]) -> None:
|
||||||
self.reports = iter(reports)
|
self.reports = iter(reports)
|
||||||
self.reviews = 0
|
self.reviews = 0
|
||||||
self.revisions = 0
|
self.revisions = 0
|
||||||
|
|
||||||
async def start(self, **_kwargs):
|
async def create_session(self, *_args):
|
||||||
self.reviews += 1
|
return "reviewer"
|
||||||
return "reviewer", next(self.reports)
|
|
||||||
|
|
||||||
async def resume(self, **kwargs):
|
async def resume(self, **kwargs):
|
||||||
if kwargs["result_type"] is ReviewReport:
|
if kwargs["result_type"] is ReviewReport:
|
||||||
@@ -60,15 +59,15 @@ class FakePrompts:
|
|||||||
|
|
||||||
|
|
||||||
def objects(rounds: int, reports: list[ReviewReport]):
|
def objects(rounds: int, reports: list[ReviewReport]):
|
||||||
codex = FakeCodex(reports)
|
opencode = FakeOpenCode(reports)
|
||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
implement_review_rounds=rounds,
|
implement_review_rounds=rounds,
|
||||||
implement_model="model",
|
implement_model="model",
|
||||||
implement_reasoning="high",
|
implement_variant="high",
|
||||||
)
|
)
|
||||||
deps = SimpleNamespace(
|
deps = SimpleNamespace(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
codex=codex,
|
opencode=opencode,
|
||||||
storage=FakeStorage(),
|
storage=FakeStorage(),
|
||||||
prompts=FakePrompts(),
|
prompts=FakePrompts(),
|
||||||
development=SimpleNamespace(description="python"),
|
development=SimpleNamespace(description="python"),
|
||||||
@@ -95,12 +94,12 @@ def objects(rounds: int, reports: list[ReviewReport]):
|
|||||||
message="",
|
message="",
|
||||||
comment_id=1,
|
comment_id=1,
|
||||||
)
|
)
|
||||||
return CodeReviewLoop(deps), codex, workflow, job # type: ignore[arg-type]
|
return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
async def test_stops_after_clean_second_review() -> None:
|
async def test_stops_after_clean_second_review() -> None:
|
||||||
clean = ReviewReport(summary="Ready", findings=[])
|
clean = ReviewReport(summary="Ready", findings=[])
|
||||||
loop, codex, workflow, job = objects(4, [serious_report(), clean])
|
loop, opencode, workflow, job = objects(4, [serious_report(), clean])
|
||||||
_, report = await loop.run(
|
_, report = await loop.run(
|
||||||
job,
|
job,
|
||||||
workflow,
|
workflow,
|
||||||
@@ -109,12 +108,12 @@ async def test_stops_after_clean_second_review() -> None:
|
|||||||
AgentResult(summary_markdown="initial", tests=[]),
|
AgentResult(summary_markdown="initial", tests=[]),
|
||||||
)
|
)
|
||||||
assert not report.has_serious_findings
|
assert not report.has_serious_findings
|
||||||
assert codex.reviews == 2
|
assert opencode.reviews == 2
|
||||||
assert codex.revisions == 1
|
assert opencode.revisions == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_does_not_make_unreviewed_final_revision() -> None:
|
async def test_does_not_make_unreviewed_final_revision() -> None:
|
||||||
loop, codex, workflow, job = objects(
|
loop, opencode, workflow, job = objects(
|
||||||
3, [serious_report(), serious_report(), serious_report()]
|
3, [serious_report(), serious_report(), serious_report()]
|
||||||
)
|
)
|
||||||
_, report = await loop.run(
|
_, report = await loop.run(
|
||||||
@@ -125,5 +124,5 @@ async def test_does_not_make_unreviewed_final_revision() -> None:
|
|||||||
AgentResult(summary_markdown="initial", tests=[]),
|
AgentResult(summary_markdown="initial", tests=[]),
|
||||||
)
|
)
|
||||||
assert report.has_serious_findings
|
assert report.has_serious_findings
|
||||||
assert codex.reviews == 3
|
assert opencode.reviews == 3
|
||||||
assert codex.revisions == 2
|
assert opencode.revisions == 2
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -27,9 +27,29 @@ def test_empty_install_scripts_disable_setup() -> None:
|
|||||||
assert settings.install_scripts == []
|
assert settings.install_scripts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_research_variant_to_high(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False)
|
||||||
|
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||||
|
assert settings.research_variant == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_explore_agent_to_luna_low() -> None:
|
||||||
|
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||||
|
assert settings.explore_model == "openai/gpt-5.6-luna"
|
||||||
|
assert settings.explore_variant == "low"
|
||||||
|
|
||||||
|
|
||||||
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
|
def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None:
|
||||||
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet")
|
||||||
|
|
||||||
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
settings = Settings(_env_file=None) # type: ignore[call-arg]
|
||||||
|
|
||||||
assert settings.install_scripts == ["python", "dotnet"]
|
assert settings.install_scripts == ["python", "dotnet"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field", ["plan_model", "implement_model", "explore_model", "research_model"]
|
||||||
|
)
|
||||||
|
def test_requires_provider_qualified_opencode_models(field: str) -> None:
|
||||||
|
with pytest.raises(ValidationError, match="provider/model"):
|
||||||
|
Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from agentci.adapters.gitea import GiteaClient
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_updates_issue_comment_by_id() -> None:
|
||||||
|
route = respx.patch(
|
||||||
|
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
|
||||||
|
).mock(return_value=httpx.Response(200, json={"id": 17}))
|
||||||
|
client = GiteaClient("https://gitea.example", "secret")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.update_comment("org", "repo", 17, "updated status")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
assert json.loads(route.calls[0].request.content) == {"body": "updated status"}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def test_dotnet_wrapper_uses_sandbox_writable_runtime_directories() -> None:
|
def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None:
|
||||||
root = Path(__file__).parents[1]
|
root = Path(__file__).parents[1]
|
||||||
script = (root / "install-scripts" / "dotnet").read_text()
|
script = (root / "install-scripts" / "dotnet").read_text()
|
||||||
|
|
||||||
assert 'DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-/tmp/agentci-dotnet}"' in script
|
assert "export DOTNET_ROOT=" in script
|
||||||
assert 'NUGET_PACKAGES="${NUGET_PACKAGES:-/tmp/agentci-nuget/packages}"' in script
|
assert "/tmp/agentci-dotnet" not in script
|
||||||
http_cache = 'NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-/tmp/agentci-nuget/http-cache}"'
|
assert "DEV_TOOLS_DIR/runtime/dotnet" in script
|
||||||
assert http_cache in script
|
assert "NUGET_PACKAGES" in script
|
||||||
assert 'export HOME="$DOTNET_CLI_HOME"' in script
|
assert 'export HOME="$DOTNET_CLI_HOME"' in script
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from agentci.logging import configure_logging
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppresses_http_client_request_logs() -> None:
|
||||||
|
configure_logging()
|
||||||
|
|
||||||
|
assert logging.getLogger("httpx").level == logging.WARNING
|
||||||
|
assert logging.getLogger("httpcore").level == logging.WARNING
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.adapters.opencode import OpenCodeClient, OpenCodeError
|
||||||
|
|
||||||
|
|
||||||
|
def client(tmp_path: Path, status: int) -> OpenCodeClient:
|
||||||
|
return OpenCodeClient(
|
||||||
|
base_url="http://opencode:4096",
|
||||||
|
username="opencode",
|
||||||
|
password="secret",
|
||||||
|
schemas_dir=tmp_path,
|
||||||
|
health_directory=tmp_path,
|
||||||
|
required_models=(),
|
||||||
|
timeout_seconds=60,
|
||||||
|
transport=httpx.MockTransport(lambda _request: httpx.Response(status)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [200, 204, 404, 409])
|
||||||
|
async def test_absent_or_inactive_session_is_success(tmp_path: Path, status: int) -> None:
|
||||||
|
value = client(tmp_path, status)
|
||||||
|
await value.abort("session", tmp_path)
|
||||||
|
await value.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [400, 429, 500])
|
||||||
|
async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) -> None:
|
||||||
|
value = client(tmp_path, status)
|
||||||
|
with pytest.raises(OpenCodeError):
|
||||||
|
await value.abort("session", tmp_path)
|
||||||
|
await value.close()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_preserves_builtin_permissions_and_restricts_research() -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
config = json.loads((root / "opencode" / "opencode.json").read_text())
|
||||||
|
|
||||||
|
assert "permission" not in config
|
||||||
|
assert all(name not in config["agent"] for name in ("build", "plan", "general"))
|
||||||
|
assert "permission" not in config["agent"]["explore"]
|
||||||
|
assert config["agent"]["research"]["permission"] == {
|
||||||
|
"*": "deny",
|
||||||
|
"websearch": "allow",
|
||||||
|
"context7_*": "allow",
|
||||||
|
"gh_grep_*": "allow",
|
||||||
|
}
|
||||||
|
assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"]
|
||||||
|
assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp"
|
||||||
|
assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}"
|
||||||
|
assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}"
|
||||||
|
assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_removes_codex_sandbox_exceptions() -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
compose = (root / "compose.yaml").read_text()
|
||||||
|
dockerfile = (root / "Dockerfile").read_text()
|
||||||
|
|
||||||
|
for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"):
|
||||||
|
assert forbidden not in compose
|
||||||
|
assert "no_cache" not in compose
|
||||||
|
assert "opencode_home:/var/lib/opencode" in compose
|
||||||
|
assert "HOME: /etc/opencode/home" in compose
|
||||||
|
assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose
|
||||||
|
assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose
|
||||||
|
assert 'OPENCODE_ENABLE_EXA: "1"' in compose
|
||||||
|
assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose
|
||||||
|
assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose
|
||||||
|
assert compose.count("/run/agentci:mode=1777") == 2
|
||||||
|
assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose
|
||||||
|
assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile
|
||||||
|
assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile
|
||||||
@@ -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")})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agentci.domain.events import (
|
||||||
|
CommandReceived,
|
||||||
|
CommentLinked,
|
||||||
|
JobCompleted,
|
||||||
|
JobStarted,
|
||||||
|
PermissionGranted,
|
||||||
|
ServiceRestarted,
|
||||||
|
)
|
||||||
|
from agentci.domain.models import JobKind, JobStatus
|
||||||
|
from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment
|
||||||
|
|
||||||
|
|
||||||
|
def received(body: str = "/agent plan message"):
|
||||||
|
return next_state(
|
||||||
|
None,
|
||||||
|
CommandReceived(
|
||||||
|
job_id="job",
|
||||||
|
delivery_id="delivery",
|
||||||
|
receive_sequence=1,
|
||||||
|
command_body=body,
|
||||||
|
target_key="org/repo:issue:1",
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
pr_number=None,
|
||||||
|
requester="alice",
|
||||||
|
comment_id=4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_parses_and_queues_execution() -> None:
|
||||||
|
transition = next_state(received().state, PermissionGranted(job_id="job"))
|
||||||
|
assert transition.state.status is JobStatus.QUEUED
|
||||||
|
assert transition.state.kind is JobKind.PLAN
|
||||||
|
assert transition.state.message == "message"
|
||||||
|
assert [(item.listener, item.queue) for item in transition.notifications] == [
|
||||||
|
("execute", "jobs"),
|
||||||
|
("reconcile_comment", "control"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_syntax_is_rejected_after_permission() -> None:
|
||||||
|
transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job"))
|
||||||
|
assert transition.state.status is JobStatus.REJECTED
|
||||||
|
assert "Unknown" in (transition.state.error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_completion_and_restart_are_explicit() -> None:
|
||||||
|
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
||||||
|
running = next_state(queued, JobStarted(job_id="job")).state
|
||||||
|
completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state
|
||||||
|
assert completed.status is JobStatus.SUCCEEDED
|
||||||
|
assert "# Result" in render_job_comment(completed)
|
||||||
|
assert next_state(completed, ServiceRestarted(job_id="job")).state == completed
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_link_is_allowed_on_terminal_state() -> None:
|
||||||
|
queued = next_state(received().state, PermissionGranted(job_id="job")).state
|
||||||
|
running = next_state(queued, JobStarted(job_id="job")).state
|
||||||
|
completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state
|
||||||
|
linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state
|
||||||
|
assert linked.accepted_comment_id == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_is_immutable_and_invalid_transitions_fail() -> None:
|
||||||
|
state = received().state
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
state.stage = "changed" # type: ignore[misc]
|
||||||
|
with pytest.raises(InvalidTransition):
|
||||||
|
next_state(state, JobStarted(job_id="job"))
|
||||||
+71
-51
@@ -1,16 +1,17 @@
|
|||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agentci.adapters.storage import Storage
|
from agentci.adapters.storage import Storage
|
||||||
from agentci.domain.models import (
|
from agentci.domain.events import (
|
||||||
Job,
|
JobStarted,
|
||||||
JobKind,
|
PermissionDenied,
|
||||||
JobStatus,
|
PermissionGranted,
|
||||||
Workflow,
|
WorkflowCreated,
|
||||||
WorkflowKind,
|
|
||||||
WorkflowStatus,
|
|
||||||
)
|
)
|
||||||
|
from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus
|
||||||
|
from agentci.state_machine import StateMachine
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -21,40 +22,60 @@ async def storage(tmp_path: Path) -> Storage:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def make_job(job_id: str = "job-1") -> Job:
|
def command(delivery: str, body: str = "/agent plan") -> CommandEvent:
|
||||||
return Job(
|
return CommandEvent(
|
||||||
id=job_id,
|
delivery_id=delivery,
|
||||||
kind=JobKind.PLAN,
|
comment_id=int(delivery.rsplit("-", 1)[-1]),
|
||||||
target_key="alice/repo:issue:3",
|
|
||||||
repo_owner="alice",
|
repo_owner="alice",
|
||||||
repo_name="repo",
|
repo_name="repo",
|
||||||
issue_number=3,
|
issue_number=3,
|
||||||
pr_number=None,
|
pr_number=None,
|
||||||
requester="alice",
|
requester="alice",
|
||||||
message="",
|
body=body,
|
||||||
comment_id=10,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_enqueue_is_idempotent_and_claims_fifo(storage: Storage) -> None:
|
async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None:
|
||||||
assert await storage.enqueue("delivery-1", make_job())
|
host = StateMachine(storage)
|
||||||
assert not await storage.enqueue("delivery-1", make_job("job-2"))
|
first = await host.receive(command("delivery-1"))
|
||||||
claimed = await storage.claim_next()
|
duplicate = await host.receive(command("delivery-1"))
|
||||||
assert claimed is not None
|
second = await host.receive(command("delivery-2"))
|
||||||
assert claimed.id == "job-1"
|
|
||||||
assert claimed.status is JobStatus.RUNNING
|
assert not first.duplicate
|
||||||
assert await storage.claim_next() is None
|
assert duplicate.duplicate
|
||||||
|
assert duplicate.state.id == first.state.id
|
||||||
|
assert second.state.receive_sequence == first.state.receive_sequence + 1
|
||||||
|
|
||||||
|
|
||||||
async def test_recovers_running_job_as_failed(storage: Storage) -> None:
|
async def test_received_job_blocks_later_execute_task(storage: Storage) -> None:
|
||||||
await storage.enqueue("delivery-1", make_job())
|
host = StateMachine(storage)
|
||||||
assert await storage.claim_next() is not None
|
first = (await host.receive(command("delivery-1"))).state
|
||||||
recovered = await storage.recover_running()
|
second = (await host.receive(command("delivery-2"))).state
|
||||||
assert [job.id for job in recovered] == ["job-1"]
|
await host.evolve("grant-2", PermissionGranted(job_id=second.id))
|
||||||
assert await storage.claim_next() is None
|
|
||||||
|
assert await storage.claim_task("jobs") is None
|
||||||
|
|
||||||
|
await host.evolve("deny-1", PermissionDenied(job_id=first.id))
|
||||||
|
task = await storage.claim_task("jobs")
|
||||||
|
assert task is not None
|
||||||
|
assert task.job_id == second.id
|
||||||
|
|
||||||
|
|
||||||
async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) -> None:
|
async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None:
|
||||||
|
host = StateMachine(storage)
|
||||||
|
state = (await host.receive(command("delivery-1"))).state
|
||||||
|
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
||||||
|
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
||||||
|
with sqlite3.connect(storage.database_path) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] is not None
|
||||||
|
assert row[1] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None:
|
||||||
workflow = Workflow(
|
workflow = Workflow(
|
||||||
id="workflow-1",
|
id="workflow-1",
|
||||||
kind=WorkflowKind.PLAN,
|
kind=WorkflowKind.PLAN,
|
||||||
@@ -67,37 +88,36 @@ async def test_persists_and_finds_workflows(storage: Storage, tmp_path: Path) ->
|
|||||||
status=WorkflowStatus.COMPLETED,
|
status=WorkflowStatus.COMPLETED,
|
||||||
)
|
)
|
||||||
await storage.create_workflow(workflow)
|
await storage.create_workflow(workflow)
|
||||||
|
await storage.fail_job_workflow("missing-job")
|
||||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
||||||
assert loaded is not None
|
assert loaded is not None
|
||||||
assert loaded.artifact == "# Plan"
|
assert loaded.status is WorkflowStatus.COMPLETED
|
||||||
assert loaded.workspace_path == tmp_path / "repo"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_tracks_operational_comments(storage: Storage) -> None:
|
async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None:
|
||||||
await storage.enqueue("delivery-1", make_job())
|
host = StateMachine(storage)
|
||||||
await storage.set_job_comment("job-1", "accepted_comment_id", 21)
|
state = (await host.receive(command("delivery-1"))).state
|
||||||
await storage.set_job_comment("job-1", "started_comment_id", 22)
|
state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state
|
||||||
assert await storage.operational_comment_ids("alice", "repo", 3) == {21, 22}
|
state = (await host.evolve("start", JobStarted(job_id=state.id))).state
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_followup_does_not_invalidate_completed_workflow(
|
|
||||||
storage: Storage, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
workflow = Workflow(
|
workflow = Workflow(
|
||||||
id="workflow-1",
|
id="workflow-atomic",
|
||||||
kind=WorkflowKind.PLAN,
|
kind=WorkflowKind.PLAN,
|
||||||
repo_owner="alice",
|
repo_owner="alice",
|
||||||
repo_name="repo",
|
repo_name="repo",
|
||||||
issue_number=3,
|
issue_number=3,
|
||||||
workspace_path=tmp_path / "repo",
|
workspace_path=tmp_path / "repo",
|
||||||
base_sha="abc",
|
base_sha="abc",
|
||||||
status=WorkflowStatus.COMPLETED,
|
|
||||||
)
|
)
|
||||||
await storage.create_workflow(workflow)
|
result = await host.evolve(
|
||||||
job = make_job()
|
"workflow-created",
|
||||||
job.workflow_id = workflow.id
|
WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"),
|
||||||
await storage.enqueue("delivery-1", job)
|
)
|
||||||
await storage.fail_job_workflow(job.id)
|
assert result.state.workflow_id == workflow.id
|
||||||
loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN)
|
assert await storage.get_workflow(workflow.id) is not None
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.status is WorkflowStatus.COMPLETED
|
|
||||||
|
async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None:
|
||||||
|
with sqlite3.connect(storage.database_path) as connection:
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")}
|
||||||
|
assert "receive_sequence" in columns
|
||||||
|
assert "version" not in columns
|
||||||
|
|||||||
+36
-76
@@ -1,50 +1,29 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
|
from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature
|
||||||
|
|
||||||
|
|
||||||
class FakeStorage:
|
class FakeHost:
|
||||||
def __init__(self) -> None:
|
def __init__(self, duplicate: bool = False) -> None:
|
||||||
self.jobs = []
|
self.events = []
|
||||||
self.deliveries: set[str] = set()
|
self.duplicate = duplicate
|
||||||
|
|
||||||
async def enqueue(self, delivery_id, job):
|
async def receive(self, event):
|
||||||
if delivery_id in self.deliveries:
|
self.events.append(event)
|
||||||
return False
|
state = SimpleNamespace(id="job", receive_sequence=1)
|
||||||
self.deliveries.add(delivery_id)
|
return SimpleNamespace(state=state, duplicate=self.duplicate)
|
||||||
self.jobs.append(job)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def record_delivery(self, delivery_id, _comment_id):
|
|
||||||
if delivery_id in self.deliveries:
|
|
||||||
return False
|
|
||||||
self.deliveries.add(delivery_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def set_job_comment(self, *_args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class FakeGitea:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.comments: list[str] = []
|
|
||||||
|
|
||||||
async def create_comment(self, _owner, _repo, _number, body):
|
|
||||||
self.comments.append(body)
|
|
||||||
return len(self.comments)
|
|
||||||
|
|
||||||
|
|
||||||
def payload(body: str, *, is_pull: bool = False) -> dict:
|
def payload(body: str, *, is_pull: bool = False) -> dict:
|
||||||
value = {
|
value = {
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
|
"comment": {"id": 8, "body": body, "user": {"login": "alice"}},
|
||||||
"repository": {
|
"repository": {"name": "repo", "owner": {"login": "org"}},
|
||||||
"name": "repo",
|
|
||||||
"owner": {"login": "org"},
|
|
||||||
},
|
|
||||||
"issue": {"number": 4},
|
"issue": {"number": 4},
|
||||||
"is_pull": is_pull,
|
"is_pull": is_pull,
|
||||||
}
|
}
|
||||||
@@ -53,59 +32,40 @@ def payload(body: str, *, is_pull: bool = False) -> dict:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def test_extracts_pull_request_event() -> None:
|
async def test_command_is_forwarded_without_parsing() -> None:
|
||||||
event = _event_from_payload("delivery", payload("/agent fix now", is_pull=True))
|
host = FakeHost()
|
||||||
assert event is not None
|
|
||||||
assert event.pr_number == 4
|
|
||||||
assert event.target_key == "org/repo:pr:4"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_authorized_command_is_queued() -> None:
|
|
||||||
storage = FakeStorage()
|
|
||||||
gitea = FakeGitea()
|
|
||||||
container = SimpleNamespace(storage=storage, gitea=gitea)
|
|
||||||
event = _event_from_payload("delivery", payload("/agent plan consider migrations"))
|
|
||||||
assert event is not None
|
|
||||||
response = await _handle_command(container, event)
|
|
||||||
assert response.status_code == 202
|
|
||||||
assert len(storage.jobs) == 1
|
|
||||||
assert "queued" in gitea.comments[0]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_iterate_message_is_preserved_on_queued_job() -> None:
|
|
||||||
storage = FakeStorage()
|
|
||||||
container = SimpleNamespace(storage=storage, gitea=FakeGitea())
|
|
||||||
event = _event_from_payload(
|
event = _event_from_payload(
|
||||||
"delivery",
|
"delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True)
|
||||||
payload(
|
|
||||||
"/agent iterate\n\nkeep the API stable\nlimit changes to the parser",
|
|
||||||
is_pull=True,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
assert event is not None
|
assert event is not None
|
||||||
|
response = await _handle_command(SimpleNamespace(state_machine=host), event)
|
||||||
response = await _handle_command(container, event)
|
|
||||||
|
|
||||||
assert response.status_code == 202
|
assert response.status_code == 202
|
||||||
assert len(storage.jobs) == 1
|
assert host.events[0].body == "/agent iterate\n\nkeep raw body"
|
||||||
assert storage.jobs[0].message == (
|
|
||||||
"keep the API stable\nlimit changes to the parser"
|
|
||||||
)
|
async def test_duplicate_returns_200() -> None:
|
||||||
|
event = _event_from_payload("delivery", payload("/agent plan"))
|
||||||
|
assert event is not None
|
||||||
|
response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_delivery_is_rejected() -> None:
|
||||||
|
event = _event_from_payload("", payload("/agent plan"))
|
||||||
|
assert event is not None
|
||||||
|
with pytest.raises(HTTPException) as raised:
|
||||||
|
await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
||||||
|
assert raised.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
async def test_non_command_is_ignored() -> None:
|
async def test_non_command_is_ignored() -> None:
|
||||||
container = SimpleNamespace(storage=FakeStorage(), gitea=FakeGitea())
|
|
||||||
event = _event_from_payload("delivery", payload("ordinary discussion"))
|
event = _event_from_payload("delivery", payload("ordinary discussion"))
|
||||||
assert event is not None
|
assert event is not None
|
||||||
response = await _handle_command(container, event)
|
response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_bad_signature() -> None:
|
def test_signature_validation() -> None:
|
||||||
|
signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest()
|
||||||
|
assert valid_signature(b"secret", b"{}", signature)
|
||||||
assert not valid_signature(b"secret", b"{}", "bad")
|
assert not valid_signature(b"secret", b"{}", "bad")
|
||||||
|
|
||||||
|
|
||||||
def test_accepts_valid_signature() -> None:
|
|
||||||
body = json.dumps(payload("ordinary comment")).encode()
|
|
||||||
signature = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
|
|
||||||
assert valid_signature(b"secret", body, signature)
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from agentci.domain.models import Workflow, WorkflowKind
|
||||||
|
from agentci.domain.state_machine import JobState
|
||||||
|
from agentci.worker import Worker, _safe_error
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStorage:
|
||||||
|
def __init__(self, workflow=None) -> None:
|
||||||
|
self.workflow = workflow
|
||||||
|
|
||||||
|
async def get_workflow(self, _workflow_id):
|
||||||
|
return self.workflow
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOpenCode:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.aborted = set()
|
||||||
|
|
||||||
|
async def abort(self, session_id, workspace):
|
||||||
|
self.aborted.add((session_id, workspace))
|
||||||
|
|
||||||
|
|
||||||
|
def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker:
|
||||||
|
return Worker(
|
||||||
|
storage=storage, # type: ignore[arg-type]
|
||||||
|
state_machine=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
gitea=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
opencode=opencode, # type: ignore[arg-type]
|
||||||
|
dispatcher=SimpleNamespace(), # type: ignore[arg-type]
|
||||||
|
poll_seconds=1,
|
||||||
|
workspaces_dir=tmp_path,
|
||||||
|
bot_username="agentci",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workflow" / "repo"
|
||||||
|
workflow = Workflow(
|
||||||
|
id="flow",
|
||||||
|
kind=WorkflowKind.IMPLEMENT,
|
||||||
|
repo_owner="org",
|
||||||
|
repo_name="repo",
|
||||||
|
issue_number=1,
|
||||||
|
workspace_path=workspace,
|
||||||
|
base_sha="base",
|
||||||
|
primary_session_id="primary",
|
||||||
|
reviewer_session_id="reviewer",
|
||||||
|
)
|
||||||
|
opencode = FakeOpenCode()
|
||||||
|
state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job")
|
||||||
|
await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions(
|
||||||
|
cast(JobState, state)
|
||||||
|
)
|
||||||
|
assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None:
|
||||||
|
opencode = FakeOpenCode()
|
||||||
|
state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job")
|
||||||
|
await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions(
|
||||||
|
cast(JobState, state)
|
||||||
|
)
|
||||||
|
assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_error_is_single_line_and_bounded() -> None:
|
||||||
|
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
|
||||||
|
assert "\n" not in value
|
||||||
|
assert len(value) == 1000
|
||||||
Reference in New Issue
Block a user