reorg files
This commit is contained in:
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="~/repos/agentci/.venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/agentci.iml" filepath="$PROJECT_DIR$/.idea/agentci.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PyToolsState">
|
||||
<option name="tools">
|
||||
<map>
|
||||
<entry key="pyright">
|
||||
<value>
|
||||
<ToolEntry>
|
||||
<option name="enabled" value="true" />
|
||||
</ToolEntry>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="ruff">
|
||||
<value>
|
||||
<ToolEntry>
|
||||
<option name="enabled" value="true" />
|
||||
</ToolEntry>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -14,12 +14,12 @@
|
||||
|
||||
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
|
||||
task claiming, and the `JobRun` event interface.
|
||||
- `src/agentci/worker.py`: durable control/job queues, authorization, execution, recovery, and
|
||||
comment reconciliation.
|
||||
- `src/agentci/application/`: runtime composition, durable control/job queues, task handlers,
|
||||
recovery, and comment reconciliation.
|
||||
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
|
||||
- `src/agentci/{gitea,git,opencode,codegraph,development}.py`: external-effect boundaries.
|
||||
- `src/agentci/{app,runtime,config,webhook,health}.py`: application lifecycle, configuration, and
|
||||
HTTP entry points.
|
||||
- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
|
||||
- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
|
||||
- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
|
||||
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
|
||||
contracts; keep these concerns outside Python orchestration.
|
||||
- `src/agentci/migrations/`: ordered SQLite migrations.
|
||||
|
||||
@@ -13,9 +13,9 @@ webhook -> repository -> reducer -> durable task -> worker -> workflow -> integr
|
||||
```
|
||||
|
||||
`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions
|
||||
atomically to SQLite and persists the resulting tasks. `worker.py` executes those tasks and passes
|
||||
an explicit `JobRun` into the functions under `workflows/`. The top-level Gitea, Git, OpenCode,
|
||||
development, and CodeGraph modules own external effects.
|
||||
atomically to SQLite and persists the resulting tasks. `application/worker/` executes those tasks
|
||||
and passes an explicit `JobRun` into the functions under `workflows/`. Modules under
|
||||
`integrations/` own external effects, while `api/` contains the FastAPI host and routes.
|
||||
|
||||
Jobs and workflows are immutable snapshots. Workflows return their final comment body directly;
|
||||
progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table
|
||||
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
|
||||
from agentci.app import create_app
|
||||
from agentci.config import Settings
|
||||
from agentci.api.app import create_app
|
||||
from agentci.config.settings import Settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -19,4 +19,3 @@ def main() -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP application and routes."""
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agentci.api.errors import register_error_handlers
|
||||
from agentci.api.lifespan import create_lifespan
|
||||
from agentci.api.routes.health import router as health_router
|
||||
from agentci.api.routes.webhook import router as webhook_router
|
||||
from agentci.config.settings import Settings
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
selected_settings = settings or Settings()
|
||||
app = FastAPI(
|
||||
title="Agent CI",
|
||||
version="0.1.0",
|
||||
lifespan=create_lifespan(selected_settings),
|
||||
)
|
||||
app.include_router(health_router)
|
||||
app.include_router(webhook_router)
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from agentci.application.runtime import Runtime
|
||||
|
||||
|
||||
def get_runtime(request: Request) -> Runtime:
|
||||
return request.app.state.runtime
|
||||
|
||||
|
||||
type RuntimeDependency = Annotated[Runtime, Depends(get_runtime)]
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
||||
log.exception(
|
||||
"unhandled HTTP request failure",
|
||||
extra={
|
||||
"operation": "http.request",
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"error_message": str(exc),
|
||||
"status_code": 500,
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error. See service logs for diagnostics."},
|
||||
)
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
app.exception_handler(Exception)(unhandled_error)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agentci.application.runtime import build_runtime
|
||||
from agentci.config.settings import Settings
|
||||
from agentci.observability.logging import configure_logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
|
||||
|
||||
|
||||
def create_lifespan(settings: Settings) -> Lifespan:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||
configure_logging()
|
||||
log.info("service startup started", extra={"operation": "service.startup"})
|
||||
try:
|
||||
runtime = await build_runtime(settings)
|
||||
except Exception:
|
||||
log.exception("service startup failed", extra={"operation": "service.startup"})
|
||||
raise
|
||||
app.state.runtime = runtime
|
||||
stop = asyncio.Event()
|
||||
worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
|
||||
try:
|
||||
log.info("service startup completed", extra={"operation": "service.startup"})
|
||||
yield
|
||||
finally:
|
||||
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
||||
stop.set()
|
||||
worker_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker_task
|
||||
finally:
|
||||
await runtime.close()
|
||||
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
||||
|
||||
return lifespan
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI route modules."""
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
from fastapi import APIRouter, Response, status
|
||||
|
||||
from agentci.runtime import Runtime
|
||||
from agentci.api.dependencies import RuntimeDependency
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -13,8 +13,7 @@ async def live() -> dict[str, str]:
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def ready(request: Request, response: Response) -> dict[str, str]:
|
||||
runtime: Runtime = request.app.state.runtime
|
||||
async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]:
|
||||
if not await runtime.opencode.ready():
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {"status": "not-ready", "reason": "opencode provider is not connected"}
|
||||
@@ -1,28 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from agentci.api.dependencies import RuntimeDependency
|
||||
from agentci.application.runtime import Runtime
|
||||
from agentci.engine.model import IncomingCommand
|
||||
from agentci.runtime import Runtime
|
||||
from agentci.integrations.gitea.webhooks import (
|
||||
SUPPORTED_EVENTS,
|
||||
incoming_command_from_payload,
|
||||
valid_signature,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
log = logging.getLogger(__name__)
|
||||
SUPPORTED_EVENTS = {
|
||||
"issue_comment",
|
||||
"pull_request_comment",
|
||||
"pull_request_review_comment",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/webhooks/gitea")
|
||||
async def webhook(request: Request) -> Response:
|
||||
runtime: Runtime = request.app.state.runtime
|
||||
async def webhook(request: Request, runtime: RuntimeDependency) -> Response:
|
||||
body = await request.body()
|
||||
signature = request.headers.get("X-Gitea-Signature", "")
|
||||
if not valid_signature(runtime.settings.webhook_secret, body, signature):
|
||||
@@ -44,7 +41,7 @@ async def webhook(request: Request) -> Response:
|
||||
payload = json.loads(body)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Webhook payload must be a JSON object")
|
||||
event = _event_from_payload(
|
||||
event = incoming_command_from_payload(
|
||||
request.headers.get("X-Gitea-Delivery", ""),
|
||||
payload,
|
||||
)
|
||||
@@ -61,11 +58,6 @@ async def webhook(request: Request) -> Response:
|
||||
return await _handle_command(runtime, event)
|
||||
|
||||
|
||||
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
return bool(signature) and hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
|
||||
extra = {"operation": "command.handle", "target": event.target_key}
|
||||
if not event.body.strip().startswith("/agent"):
|
||||
@@ -90,29 +82,3 @@ async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
|
||||
},
|
||||
)
|
||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> IncomingCommand | None:
|
||||
if payload.get("action") != "created":
|
||||
return None
|
||||
comment = payload["comment"]
|
||||
repository = payload["repository"]
|
||||
owner = repository["owner"]
|
||||
owner_name = owner.get("login") or owner.get("username") or owner["name"]
|
||||
pull = payload.get("pull_request")
|
||||
is_pull = bool(payload.get("is_pull") or pull)
|
||||
issue = payload.get("issue")
|
||||
target = pull or issue
|
||||
if target is None:
|
||||
raise ValueError("Comment payload has no issue or pull request")
|
||||
number = int(target["number"])
|
||||
return IncomingCommand(
|
||||
delivery_id=delivery_id,
|
||||
comment_id=int(comment["id"]),
|
||||
repo_owner=owner_name,
|
||||
repo_name=repository["name"],
|
||||
issue_number=number,
|
||||
pr_number=number if is_pull else None,
|
||||
requester=comment["user"]["login"],
|
||||
body=comment.get("body") or "",
|
||||
)
|
||||
@@ -1,71 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from agentci.config import Settings
|
||||
from agentci.health import router as health_router
|
||||
from agentci.logging import configure_logging
|
||||
from agentci.runtime import build_runtime
|
||||
from agentci.webhook import router as webhook_router
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
selected_settings = settings or Settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
configure_logging()
|
||||
log.info("service startup started", extra={"operation": "service.startup"})
|
||||
try:
|
||||
runtime = await build_runtime(selected_settings)
|
||||
except Exception:
|
||||
log.exception("service startup failed", extra={"operation": "service.startup"})
|
||||
raise
|
||||
app.state.runtime = runtime
|
||||
stop = asyncio.Event()
|
||||
worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker")
|
||||
try:
|
||||
log.info("service startup completed", extra={"operation": "service.startup"})
|
||||
yield
|
||||
finally:
|
||||
log.info("service shutdown started", extra={"operation": "service.shutdown"})
|
||||
stop.set()
|
||||
worker_task.cancel()
|
||||
try:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker_task
|
||||
finally:
|
||||
await runtime.close()
|
||||
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
|
||||
|
||||
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
|
||||
app.include_router(health_router)
|
||||
app.include_router(webhook_router)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
|
||||
log.exception(
|
||||
"unhandled HTTP request failure",
|
||||
extra={
|
||||
"operation": "http.request",
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": 500,
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error. See service logs for diagnostics."},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Application composition and durable orchestration."""
|
||||
@@ -2,16 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.config import Settings
|
||||
from agentci.development import DevelopmentEnvironment
|
||||
from agentci.application.worker.runner import Worker
|
||||
from agentci.config.settings import Settings
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.git import Git
|
||||
from agentci.gitea import Gitea
|
||||
from agentci.opencode import OpenCode
|
||||
from agentci.prompts import PromptLibrary
|
||||
from agentci.worker import Worker
|
||||
from agentci.integrations.development import DevelopmentEnvironment
|
||||
from agentci.integrations.git import Git
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
from agentci.prompts.library import PromptLibrary
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -36,10 +35,9 @@ class Runtime:
|
||||
|
||||
async def build_runtime(settings: Settings) -> Runtime:
|
||||
log.info("runtime initialization started", extra={"operation": "runtime.build"})
|
||||
package_dir = Path(__file__).parent
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
|
||||
repository = Repository(settings.database_path, package_dir / "migrations")
|
||||
repository = Repository(settings.database_path)
|
||||
await repository.initialize()
|
||||
gitea = Gitea(settings.gitea_url, settings.gitea_token)
|
||||
git = Git(
|
||||
@@ -50,11 +48,12 @@ async def build_runtime(settings: Settings) -> Runtime:
|
||||
commit_name=settings.bot_name,
|
||||
commit_email=settings.bot_email,
|
||||
)
|
||||
prompts = PromptLibrary()
|
||||
opencode = OpenCode(
|
||||
base_url=settings.opencode_url,
|
||||
username=settings.opencode_server_username,
|
||||
password=settings.opencode_server_password,
|
||||
schemas_dir=package_dir / "prompts" / "schemas",
|
||||
schemas_dir=prompts.schemas_dir,
|
||||
health_directory=settings.workspaces_dir,
|
||||
required_models=(
|
||||
(settings.plan_model, settings.plan_variant),
|
||||
@@ -78,7 +77,7 @@ async def build_runtime(settings: Settings) -> Runtime:
|
||||
gitea=gitea,
|
||||
git=git,
|
||||
opencode=opencode,
|
||||
prompts=PromptLibrary(),
|
||||
prompts=prompts,
|
||||
development=development,
|
||||
)
|
||||
worker = Worker(
|
||||
@@ -0,0 +1 @@
|
||||
"""Durable task runner and task handlers."""
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import PermissionDenied, PermissionGranted
|
||||
from agentci.engine.model import Job, JobStatus, Task
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
|
||||
|
||||
async def authorize_job(
|
||||
*, task: Task, job: Job, repository: Repository, gitea: Gitea
|
||||
) -> None:
|
||||
if job.status is not JobStatus.RECEIVED:
|
||||
return
|
||||
permitted = await gitea.has_write_permission(job.repo_owner, job.repo_name, job.requester)
|
||||
event = PermissionGranted(job_id=job.id) if permitted else PermissionDenied(job_id=job.id)
|
||||
outcome = "permission-granted" if permitted else "permission-denied"
|
||||
await repository.apply(f"task:{task.id}:{outcome}", event)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import CommentLinked
|
||||
from agentci.engine.model import Job, Task
|
||||
from agentci.engine.reducer import render_job_comment
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
|
||||
|
||||
async def reconcile_comment(
|
||||
*,
|
||||
task: Task,
|
||||
job: Job,
|
||||
repository: Repository,
|
||||
gitea: Gitea,
|
||||
bot_username: str,
|
||||
) -> None:
|
||||
latest = await repository.get_job(job.id)
|
||||
if latest is None:
|
||||
return
|
||||
body = render_job_comment(latest)
|
||||
comment_id = latest.accepted_comment_id
|
||||
if comment_id is not None and await 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 gitea.issue_comments(
|
||||
latest.repo_owner, latest.repo_name, latest.issue_number
|
||||
)
|
||||
if comment.body.startswith(marker)
|
||||
and comment.author.casefold() == bot_username.casefold()
|
||||
)
|
||||
if matches:
|
||||
comment_id = matches[0]
|
||||
else:
|
||||
comment_id = await gitea.create_comment(
|
||||
latest.repo_owner, latest.repo_name, latest.issue_number, body
|
||||
)
|
||||
await repository.apply(
|
||||
f"task:{task.id}:comment:{comment_id}",
|
||||
CommentLinked(job_id=latest.id, comment_id=comment_id),
|
||||
)
|
||||
await gitea.update_comment(
|
||||
latest.repo_owner, latest.repo_name, comment_id, body
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def safe_error(error: Exception) -> str:
|
||||
message = " ".join(str(error).split())
|
||||
return f"{type(error).__name__}: {message}"[:1000]
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.application.worker.errors import safe_error
|
||||
from agentci.engine.events import JobCompleted, JobFailed, JobStarted, ServiceRestarted
|
||||
from agentci.engine.events import JobRejected as RejectedEvent
|
||||
from agentci.engine.model import Job, JobStatus, Task
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.workflows.dispatch import dispatch
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
|
||||
async def execute_job(
|
||||
*, task: Task, job: Job, repository: Repository, services: WorkflowServices
|
||||
) -> None:
|
||||
if job.status is JobStatus.RUNNING:
|
||||
await repository.apply(
|
||||
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
|
||||
)
|
||||
return
|
||||
if job.status is not JobStatus.QUEUED:
|
||||
return
|
||||
result = await repository.apply(f"task:{task.id}:started", JobStarted(job_id=job.id))
|
||||
running = result.job
|
||||
run = JobRun(repository, job.id, task.id)
|
||||
try:
|
||||
body = await dispatch(running, run, services)
|
||||
except JobRejected as exc:
|
||||
await repository.apply(
|
||||
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
|
||||
)
|
||||
except Exception as exc:
|
||||
latest = await repository.get_job(job.id)
|
||||
stage = latest.stage if latest else running.stage
|
||||
await repository.apply(
|
||||
f"task:{task.id}:failed",
|
||||
JobFailed(job_id=job.id, error=safe_error(exc), stage=stage),
|
||||
)
|
||||
else:
|
||||
await repository.apply(
|
||||
f"task:{task.id}:completed",
|
||||
JobCompleted(
|
||||
job_id=job.id,
|
||||
comment_body=body or "Agent job completed.",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agentci.engine.events import ServiceRestarted
|
||||
from agentci.engine.repository import Repository
|
||||
|
||||
|
||||
async def recover_jobs(repository: Repository) -> None:
|
||||
await repository.recover_tasks()
|
||||
for job in await repository.running_jobs():
|
||||
await repository.apply(
|
||||
f"recovery:{job.id}:service-restarted",
|
||||
ServiceRestarted(job_id=job.id),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.application.worker.authorization import authorize_job
|
||||
from agentci.application.worker.comments import reconcile_comment
|
||||
from agentci.application.worker.errors import safe_error
|
||||
from agentci.application.worker.execution import execute_job
|
||||
from agentci.application.worker.recovery import recover_jobs
|
||||
from agentci.application.worker.sessions import abort_job_sessions
|
||||
from agentci.engine.model import Job, QueueName, Task, TaskKind
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: Repository,
|
||||
gitea: Gitea,
|
||||
opencode: OpenCode,
|
||||
services: WorkflowServices,
|
||||
poll_seconds: float,
|
||||
max_concurrent_jobs: int,
|
||||
workspaces_dir: Path,
|
||||
bot_username: str,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.gitea = gitea
|
||||
self.opencode = opencode
|
||||
self.services = services
|
||||
self.poll_seconds = poll_seconds
|
||||
self.max_concurrent_jobs = max_concurrent_jobs
|
||||
self.workspaces_dir = workspaces_dir
|
||||
self.bot_username = bot_username
|
||||
|
||||
async def run(self, stop: asyncio.Event) -> None:
|
||||
await self._recover()
|
||||
await asyncio.gather(
|
||||
self._loop(QueueName.CONTROL, stop),
|
||||
*(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
|
||||
)
|
||||
|
||||
async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
|
||||
while not stop.is_set():
|
||||
if queue is QueueName.JOBS and not await self.opencode.ready():
|
||||
await self._wait(stop)
|
||||
continue
|
||||
task = await self.repository.claim_task(queue)
|
||||
if task is None:
|
||||
await self._wait(stop)
|
||||
continue
|
||||
try:
|
||||
await self._handle(task)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"listener failed",
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"listener": task.kind.value,
|
||||
"queue": queue.value,
|
||||
},
|
||||
)
|
||||
await self.repository.retry_task(task.id, task.attempts, safe_error(exc))
|
||||
else:
|
||||
await self.repository.complete_task(task.id)
|
||||
|
||||
async def _handle(self, task: Task) -> None:
|
||||
job = await self.repository.get_job(task.job_id)
|
||||
if job is None:
|
||||
return
|
||||
match task.kind:
|
||||
case TaskKind.AUTHORIZE:
|
||||
await self._authorize(task, job)
|
||||
case TaskKind.EXECUTE:
|
||||
await self._execute(task, job)
|
||||
case TaskKind.RECONCILE_COMMENT:
|
||||
await self._reconcile(task, job)
|
||||
case TaskKind.FAIL_WORKFLOW:
|
||||
await self.repository.fail_job_workflow(job.id)
|
||||
case TaskKind.ABORT_SESSIONS:
|
||||
await self._abort_job_sessions(job)
|
||||
case _:
|
||||
raise RuntimeError(f"Unknown task kind {task.kind}")
|
||||
|
||||
async def _authorize(self, task: Task, job: Job) -> None:
|
||||
await authorize_job(
|
||||
task=task,
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
gitea=self.gitea,
|
||||
)
|
||||
|
||||
async def _execute(self, task: Task, job: Job) -> None:
|
||||
await execute_job(
|
||||
task=task,
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
services=self.services,
|
||||
)
|
||||
|
||||
async def _reconcile(self, task: Task, job: Job) -> None:
|
||||
await reconcile_comment(
|
||||
task=task,
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
gitea=self.gitea,
|
||||
bot_username=self.bot_username,
|
||||
)
|
||||
|
||||
async def _recover(self) -> None:
|
||||
await recover_jobs(self.repository)
|
||||
|
||||
async def _abort_job_sessions(self, job: Job) -> None:
|
||||
await abort_job_sessions(
|
||||
job=job,
|
||||
repository=self.repository,
|
||||
opencode=self.opencode,
|
||||
workspaces_dir=self.workspaces_dir,
|
||||
)
|
||||
|
||||
async def _wait(self, stop: asyncio.Event) -> None:
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.engine.model import Job
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
|
||||
|
||||
async def abort_job_sessions(
|
||||
*,
|
||||
job: Job,
|
||||
repository: Repository,
|
||||
opencode: OpenCode,
|
||||
workspaces_dir: Path,
|
||||
) -> None:
|
||||
sessions: set[tuple[str, Path]] = set()
|
||||
workflow = await repository.get_workflow(job.workflow_id) if job.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 job.runtime_session_id:
|
||||
sessions.add(
|
||||
(job.runtime_session_id, workspaces_dir / f"fix-{job.id}" / "repo")
|
||||
)
|
||||
for session, workspace in sessions:
|
||||
await opencode.abort(session, workspace)
|
||||
@@ -0,0 +1 @@
|
||||
"""Service configuration."""
|
||||
@@ -0,0 +1 @@
|
||||
"""External service and process integrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Gitea API and webhook integration."""
|
||||
@@ -2,49 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssueInfo:
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommentInfo:
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PullRequestInfo:
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
merged: bool
|
||||
base_branch: str
|
||||
head_branch: str
|
||||
head_sha: str
|
||||
head_owner: str
|
||||
head_repo: str
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.state == "open" and not self.merged
|
||||
|
||||
|
||||
class GiteaError(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssueInfo:
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommentInfo:
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PullRequestInfo:
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
merged: bool
|
||||
base_branch: str
|
||||
head_branch: str
|
||||
head_sha: str
|
||||
head_owner: str
|
||||
head_repo: str
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.state == "open" and not self.merged
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
from agentci.engine.model import IncomingCommand
|
||||
|
||||
SUPPORTED_EVENTS = {
|
||||
"issue_comment",
|
||||
"pull_request_comment",
|
||||
"pull_request_review_comment",
|
||||
}
|
||||
|
||||
|
||||
def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
return bool(signature) and hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def incoming_command_from_payload(
|
||||
delivery_id: str, payload: dict[str, Any]
|
||||
) -> IncomingCommand | None:
|
||||
if payload.get("action") != "created":
|
||||
return None
|
||||
comment = payload["comment"]
|
||||
repository = payload["repository"]
|
||||
owner = repository["owner"]
|
||||
owner_name = owner.get("login") or owner.get("username") or owner["name"]
|
||||
pull = payload.get("pull_request")
|
||||
is_pull = bool(payload.get("is_pull") or pull)
|
||||
issue = payload.get("issue")
|
||||
target = pull or issue
|
||||
if target is None:
|
||||
raise ValueError("Comment payload has no issue or pull request")
|
||||
number = int(target["number"])
|
||||
return IncomingCommand(
|
||||
delivery_id=delivery_id,
|
||||
comment_id=int(comment["id"]),
|
||||
repo_owner=owner_name,
|
||||
repo_name=repository["name"],
|
||||
issue_number=number,
|
||||
pr_number=number if is_pull else None,
|
||||
requester=comment["user"]["login"],
|
||||
body=comment.get("body") or "",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""OpenCode API integration."""
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -11,7 +10,9 @@ from typing import Any, TypeVar
|
||||
import httpx
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from agentci.codegraph import CodeGraph
|
||||
from agentci.integrations.codegraph import CodeGraph
|
||||
from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
|
||||
from agentci.integrations.opencode.schemas import load_schema
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -265,67 +266,3 @@ def elapsed_ms(started: float) -> int:
|
||||
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"}
|
||||
for path, method in fixed.items():
|
||||
operations = paths.get(path)
|
||||
if not isinstance(operations, dict) or method not in operations:
|
||||
return False
|
||||
session_paths = [
|
||||
path
|
||||
for path, operations in paths.items()
|
||||
if isinstance(path, str) and isinstance(operations, dict) and 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_value = payload.get("connected")
|
||||
provider_values = payload.get("all")
|
||||
if not isinstance(connected_value, list) or not all(
|
||||
isinstance(item, str) for item in connected_value
|
||||
):
|
||||
return False
|
||||
if not isinstance(provider_values, list):
|
||||
return False
|
||||
connected = set(connected_value)
|
||||
providers: dict[str, dict[str, Any]] = {}
|
||||
for item in provider_values:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
provider_id = item.get("id")
|
||||
if isinstance(provider_id, str) and isinstance(item.get("models"), dict):
|
||||
providers[provider_id] = item
|
||||
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
|
||||
capabilities = model.get("capabilities")
|
||||
if not isinstance(capabilities, dict) or capabilities.get("toolcall") is not True:
|
||||
return False
|
||||
if variant:
|
||||
variants = model.get("variants")
|
||||
if not isinstance(variants, dict) or variant not in variants:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
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"}
|
||||
for path, method in fixed.items():
|
||||
operations = paths.get(path)
|
||||
if not isinstance(operations, dict) or method not in operations:
|
||||
return False
|
||||
session_paths = [
|
||||
path
|
||||
for path, operations in paths.items()
|
||||
if isinstance(path, str) and isinstance(operations, dict) and 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_value = payload.get("connected")
|
||||
provider_values = payload.get("all")
|
||||
if not isinstance(connected_value, list) or not all(
|
||||
isinstance(item, str) for item in connected_value
|
||||
):
|
||||
return False
|
||||
if not isinstance(provider_values, list):
|
||||
return False
|
||||
connected = set(connected_value)
|
||||
providers: dict[str, dict[str, Any]] = {}
|
||||
for item in provider_values:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
provider_id = item.get("id")
|
||||
if isinstance(provider_id, str) and isinstance(item.get("models"), dict):
|
||||
providers[provider_id] = item
|
||||
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
|
||||
capabilities = model.get("capabilities")
|
||||
if not isinstance(capabilities, dict) or capabilities.get("toolcall") is not True:
|
||||
return False
|
||||
if variant:
|
||||
variants = model.get("variants")
|
||||
if not isinstance(variants, dict) or variant not in variants:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""Service observability configuration."""
|
||||
@@ -15,6 +15,7 @@ LOG_FIELDS = (
|
||||
"method",
|
||||
"path",
|
||||
"status_code",
|
||||
"error_message",
|
||||
"attempt",
|
||||
"item_count",
|
||||
"duration_ms",
|
||||
@@ -1,14 +1 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
|
||||
class PromptLibrary:
|
||||
def __init__(self, directory: Path | None = None) -> None:
|
||||
self.directory = directory or Path(__file__).parent
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
template = Template((self.directory / f"{name}.md").read_text())
|
||||
return template.substitute(values)
|
||||
|
||||
"""Prompt templates and structured-output contracts."""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
|
||||
class PromptLibrary:
|
||||
def __init__(self, directory: Path | None = None) -> None:
|
||||
self.directory = directory or Path(__file__).parent
|
||||
|
||||
def render(self, name: str, **values: str) -> str:
|
||||
template = Template((self.directory / f"{name}.md").read_text())
|
||||
return template.substitute(values)
|
||||
|
||||
@property
|
||||
def schemas_dir(self) -> Path:
|
||||
return self.directory / "schemas"
|
||||
@@ -1,221 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from agentci.engine.events import (
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
JobFailed,
|
||||
JobStarted,
|
||||
PermissionDenied,
|
||||
PermissionGranted,
|
||||
ServiceRestarted,
|
||||
)
|
||||
from agentci.engine.events import (
|
||||
JobRejected as RejectedEvent,
|
||||
)
|
||||
from agentci.engine.model import Job, JobStatus, QueueName, Task, TaskKind
|
||||
from agentci.engine.reducer import render_job_comment
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import Gitea
|
||||
from agentci.opencode import OpenCode
|
||||
from agentci.workflows.dispatch import dispatch
|
||||
from agentci.workflows.render import JobRejected
|
||||
from agentci.workflows.services import WorkflowServices
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: Repository,
|
||||
gitea: Gitea,
|
||||
opencode: OpenCode,
|
||||
services: WorkflowServices,
|
||||
poll_seconds: float,
|
||||
max_concurrent_jobs: int,
|
||||
workspaces_dir: Path,
|
||||
bot_username: str,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.gitea = gitea
|
||||
self.opencode = opencode
|
||||
self.services = services
|
||||
self.poll_seconds = poll_seconds
|
||||
self.max_concurrent_jobs = max_concurrent_jobs
|
||||
self.workspaces_dir = workspaces_dir
|
||||
self.bot_username = bot_username
|
||||
|
||||
async def run(self, stop: asyncio.Event) -> None:
|
||||
await self._recover()
|
||||
await asyncio.gather(
|
||||
self._loop(QueueName.CONTROL, stop),
|
||||
*(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)),
|
||||
)
|
||||
|
||||
async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None:
|
||||
while not stop.is_set():
|
||||
if queue is QueueName.JOBS and not await self.opencode.ready():
|
||||
await self._wait(stop)
|
||||
continue
|
||||
task = await self.repository.claim_task(queue)
|
||||
if task is None:
|
||||
await self._wait(stop)
|
||||
continue
|
||||
try:
|
||||
await self._handle(task)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"listener failed",
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"listener": task.kind.value,
|
||||
"queue": queue.value,
|
||||
},
|
||||
)
|
||||
await self.repository.retry_task(task.id, task.attempts, _safe_error(exc))
|
||||
else:
|
||||
await self.repository.complete_task(task.id)
|
||||
|
||||
async def _handle(self, task: Task) -> None:
|
||||
job = await self.repository.get_job(task.job_id)
|
||||
if job is None:
|
||||
return
|
||||
match task.kind:
|
||||
case TaskKind.AUTHORIZE:
|
||||
await self._authorize(task, job)
|
||||
case TaskKind.EXECUTE:
|
||||
await self._execute(task, job)
|
||||
case TaskKind.RECONCILE_COMMENT:
|
||||
await self._reconcile(task, job)
|
||||
case TaskKind.FAIL_WORKFLOW:
|
||||
await self.repository.fail_job_workflow(job.id)
|
||||
case TaskKind.ABORT_SESSIONS:
|
||||
await self._abort_job_sessions(job)
|
||||
case _:
|
||||
raise RuntimeError(f"Unknown task kind {task.kind}")
|
||||
|
||||
async def _authorize(self, task: Task, job: Job) -> None:
|
||||
if job.status is not JobStatus.RECEIVED:
|
||||
return
|
||||
permitted = await self.gitea.has_write_permission(
|
||||
job.repo_owner, job.repo_name, job.requester
|
||||
)
|
||||
event = (
|
||||
PermissionGranted(job_id=job.id)
|
||||
if permitted
|
||||
else PermissionDenied(job_id=job.id)
|
||||
)
|
||||
outcome = "permission-granted" if permitted else "permission-denied"
|
||||
await self.repository.apply(f"task:{task.id}:{outcome}", event)
|
||||
|
||||
async def _execute(self, task: Task, job: Job) -> None:
|
||||
if job.status is JobStatus.RUNNING:
|
||||
await self.repository.apply(
|
||||
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
|
||||
)
|
||||
return
|
||||
if job.status is not JobStatus.QUEUED:
|
||||
return
|
||||
result = await self.repository.apply(
|
||||
f"task:{task.id}:started", JobStarted(job_id=job.id)
|
||||
)
|
||||
running = result.job
|
||||
run = JobRun(self.repository, job.id, task.id)
|
||||
try:
|
||||
body = await dispatch(running, run, self.services)
|
||||
except JobRejected as exc:
|
||||
await self.repository.apply(
|
||||
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
|
||||
)
|
||||
except Exception as exc:
|
||||
latest = await self.repository.get_job(job.id)
|
||||
stage = latest.stage if latest else running.stage
|
||||
await self.repository.apply(
|
||||
f"task:{task.id}:failed",
|
||||
JobFailed(job_id=job.id, error=_safe_error(exc), stage=stage),
|
||||
)
|
||||
else:
|
||||
await self.repository.apply(
|
||||
f"task:{task.id}:completed",
|
||||
JobCompleted(
|
||||
job_id=job.id,
|
||||
comment_body=body or "Agent job completed.",
|
||||
),
|
||||
)
|
||||
|
||||
async def _reconcile(self, task: Task, job: Job) -> None:
|
||||
latest = await self.repository.get_job(job.id)
|
||||
if latest is None:
|
||||
return
|
||||
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.repository.apply(
|
||||
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.repository.recover_tasks()
|
||||
for job in await self.repository.running_jobs():
|
||||
await self.repository.apply(
|
||||
f"recovery:{job.id}:service-restarted",
|
||||
ServiceRestarted(job_id=job.id),
|
||||
)
|
||||
|
||||
async def _abort_job_sessions(self, job: Job) -> None:
|
||||
sessions: set[tuple[str, Path]] = set()
|
||||
workflow = (
|
||||
await self.repository.get_workflow(job.workflow_id) if job.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 job.runtime_session_id:
|
||||
sessions.add(
|
||||
(job.runtime_session_id, self.workspaces_dir / f"fix-{job.id}" / "repo")
|
||||
)
|
||||
for session, workspace in sessions:
|
||||
await self.opencode.abort(session, workspace)
|
||||
|
||||
async def _wait(self, stop: asyncio.Event) -> None:
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
|
||||
|
||||
|
||||
def _safe_error(error: Exception) -> str:
|
||||
message = " ".join(str(error).split())
|
||||
return f"{type(error).__name__}: {message}"[:1000]
|
||||
@@ -4,7 +4,8 @@ import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.gitea import CommentInfo, Gitea, PullRequestInfo
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo
|
||||
|
||||
|
||||
async def build_issue_context(
|
||||
|
||||
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agentci.config import Settings
|
||||
from agentci.development import DevelopmentEnvironment
|
||||
from agentci.config.settings import Settings
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.git import Git
|
||||
from agentci.gitea import Gitea
|
||||
from agentci.opencode import OpenCode
|
||||
from agentci.prompts import PromptLibrary
|
||||
from agentci.integrations.development import DevelopmentEnvironment
|
||||
from agentci.integrations.git import Git
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.opencode.client import OpenCode
|
||||
from agentci.prompts.library import PromptLibrary
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+8
-7
@@ -4,7 +4,8 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import agentci.app as app_module
|
||||
import agentci.api.app as app_module
|
||||
import agentci.api.lifespan as lifespan_module
|
||||
|
||||
|
||||
class BlockingWorker:
|
||||
@@ -56,8 +57,8 @@ async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
|
||||
built_with.append(settings)
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(app_module, "build_runtime", build)
|
||||
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
|
||||
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
||||
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
||||
application = app_module.create_app(selected_settings) # type: ignore[arg-type]
|
||||
|
||||
async with application.router.lifespan_context(application):
|
||||
@@ -80,8 +81,8 @@ async def test_lifespan_closes_runtime_when_worker_task_fails(
|
||||
async def build(_settings: object) -> FakeRuntime:
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(app_module, "build_runtime", build)
|
||||
monkeypatch.setattr(app_module, "configure_logging", lambda: None)
|
||||
monkeypatch.setattr(lifespan_module, "build_runtime", build)
|
||||
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None)
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="worker failed"):
|
||||
@@ -100,8 +101,8 @@ async def test_lifespan_propagates_runtime_startup_failure_without_starting_work
|
||||
async def fail_build(_settings: object) -> None:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(app_module, "build_runtime", fail_build)
|
||||
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True))
|
||||
monkeypatch.setattr(lifespan_module, "build_runtime", fail_build)
|
||||
monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
|
||||
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.codegraph import CodeGraph, CodeGraphError
|
||||
from agentci.integrations.codegraph import CodeGraph, CodeGraphError
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
@@ -27,7 +27,7 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ async def test_handles_exclude_file_boundaries(
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.Mo
|
||||
raise FileNotFoundError(2, "No such file or directory", "codegraph")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
@@ -125,7 +125,7 @@ async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
|
||||
return FakeProcess(returncode=7, stderr=stderr)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.codegraph.asyncio.create_subprocess_exec",
|
||||
"agentci.integrations.codegraph.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from agentci.config import Settings
|
||||
from agentci.config.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import cast
|
||||
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.gitea import CommentInfo, Gitea, IssueInfo, PullRequestInfo
|
||||
from agentci.integrations.gitea.client import Gitea
|
||||
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.context import build_issue_context, build_pull_request_context
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.development import (
|
||||
from agentci.integrations.development import (
|
||||
DevelopmentEnvironment,
|
||||
DevelopmentEnvironmentError,
|
||||
)
|
||||
@@ -169,7 +169,7 @@ async def test_wraps_subprocess_start_error(
|
||||
raise OSError("exec unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agentci.development.asyncio.create_subprocess_exec",
|
||||
"agentci.integrations.development.asyncio.create_subprocess_exec",
|
||||
create_subprocess_exec,
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.git import Git, GitError
|
||||
from agentci.integrations.git import Git, GitError
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
|
||||
+9
-8
@@ -1,17 +1,18 @@
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from collections.abc import AsyncGenerator, Callable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo
|
||||
from agentci.integrations.gitea.client import Gitea, GiteaError
|
||||
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||
|
||||
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]:
|
||||
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]:
|
||||
client = Gitea(
|
||||
"https://gitea.example/",
|
||||
"secret",
|
||||
@@ -232,7 +233,7 @@ async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.M
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
with pytest.raises(
|
||||
GiteaError,
|
||||
@@ -261,7 +262,7 @@ async def test_retryable_status_recovers_after_backoff(
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
assert await client.default_branch("org", "repo") == "main"
|
||||
|
||||
@@ -283,7 +284,7 @@ async def test_retryable_status_exhaustion_uses_exponential_backoff(
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
with pytest.raises(
|
||||
GiteaError,
|
||||
@@ -309,7 +310,7 @@ async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.Monkey
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler) as client:
|
||||
assert await client.default_branch("org", "repo") == "main"
|
||||
|
||||
@@ -331,7 +332,7 @@ async def test_transport_failure_exhaustion_preserves_cause(
|
||||
async def sleep(delay: int) -> None:
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
|
||||
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
|
||||
async with gitea_client(handler, retries=2) as client:
|
||||
with pytest.raises(
|
||||
GiteaError,
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient, Response
|
||||
|
||||
from agentci.health import router
|
||||
from agentci.api.routes.health import router
|
||||
|
||||
|
||||
class Provider:
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.logging import JsonFormatter, configure_logging
|
||||
from agentci.observability.logging import JsonFormatter, configure_logging
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.opencode import OpenCode, OpenCodeError
|
||||
from agentci.integrations.opencode.client import OpenCode, OpenCodeError
|
||||
from agentci.workflows.model import AgentResult
|
||||
|
||||
API_DOCUMENT = {
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agentci.opencode import OpenCode, OpenCodeError
|
||||
from agentci.integrations.opencode.client import OpenCode, OpenCodeError
|
||||
|
||||
|
||||
def client(tmp_path: Path, status: int) -> OpenCode:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from agentci.opencode import api_contract_ready, models_ready
|
||||
from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
|
||||
|
||||
|
||||
def test_api_contract_requires_session_message_and_abort_routes() -> None:
|
||||
|
||||
@@ -5,8 +5,8 @@ import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
import agentci.prompts
|
||||
from agentci.opencode import load_schema
|
||||
from agentci.prompts import PromptLibrary
|
||||
from agentci.integrations.opencode.schemas import load_schema
|
||||
from agentci.prompts.library import PromptLibrary
|
||||
from agentci.workflows.model import (
|
||||
AgentResult,
|
||||
DiscussionReply,
|
||||
|
||||
+6
-10
@@ -4,9 +4,9 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import agentci.runtime as runtime_module
|
||||
from agentci.config import Settings
|
||||
from agentci.runtime import Runtime
|
||||
import agentci.application.runtime as runtime_module
|
||||
from agentci.application.runtime import Runtime
|
||||
from agentci.config.settings import Settings
|
||||
|
||||
|
||||
class ClosingClient:
|
||||
@@ -90,7 +90,7 @@ async def test_build_runtime_wires_components_without_starting_real_clients(
|
||||
git = SimpleNamespace()
|
||||
opencode = SimpleNamespace()
|
||||
development = SimpleNamespace()
|
||||
prompts = SimpleNamespace()
|
||||
prompts = SimpleNamespace(schemas_dir=tmp_path / "schemas")
|
||||
services = SimpleNamespace()
|
||||
worker = SimpleNamespace()
|
||||
repository_constructor = Mock(return_value=repository)
|
||||
@@ -112,11 +112,7 @@ async def test_build_runtime_wires_components_without_starting_real_clients(
|
||||
|
||||
built = await runtime_module.build_runtime(settings)
|
||||
|
||||
assert runtime_module.__file__ is not None
|
||||
package_dir = Path(runtime_module.__file__).parent
|
||||
repository_constructor.assert_called_once_with(
|
||||
settings.database_path, package_dir / "migrations"
|
||||
)
|
||||
repository_constructor.assert_called_once_with(settings.database_path)
|
||||
initialize.assert_awaited_once_with()
|
||||
gitea_constructor.assert_called_once_with("https://gitea.example", "token")
|
||||
git_constructor.assert_called_once_with(
|
||||
@@ -131,7 +127,7 @@ async def test_build_runtime_wires_components_without_starting_real_clients(
|
||||
base_url="https://opencode.example",
|
||||
username=settings.opencode_server_username,
|
||||
password="password",
|
||||
schemas_dir=package_dir / "prompts" / "schemas",
|
||||
schemas_dir=prompts.schemas_dir,
|
||||
health_directory=settings.workspaces_dir,
|
||||
required_models=(
|
||||
(settings.plan_model, settings.plan_variant),
|
||||
|
||||
@@ -7,8 +7,12 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient, Response
|
||||
|
||||
from agentci.api.routes.webhook import router
|
||||
from agentci.engine.model import IncomingCommand
|
||||
from agentci.webhook import _event_from_payload, router, valid_signature
|
||||
from agentci.integrations.gitea.webhooks import (
|
||||
incoming_command_from_payload,
|
||||
valid_signature,
|
||||
)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
@@ -167,7 +171,7 @@ def test_payload_parser_supports_owner_username_and_pull_request() -> None:
|
||||
value = payload("/agent plan", is_pull=True)
|
||||
value["repository"]["owner"] = {"username": "fallback-owner"}
|
||||
|
||||
event = _event_from_payload("delivery", value)
|
||||
event = incoming_command_from_payload("delivery", value)
|
||||
|
||||
assert event is not None
|
||||
assert event.repo_owner == "fallback-owner"
|
||||
|
||||
@@ -5,6 +5,8 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agentci.application.worker.errors import safe_error
|
||||
from agentci.application.worker.runner import Worker
|
||||
from agentci.engine.events import (
|
||||
CommentLinked,
|
||||
JobCompleted,
|
||||
@@ -30,8 +32,7 @@ from agentci.engine.model import (
|
||||
)
|
||||
from agentci.engine.reducer import render_job_comment
|
||||
from agentci.engine.repository import Repository
|
||||
from agentci.gitea import CommentInfo
|
||||
from agentci.worker import Worker, _safe_error
|
||||
from agentci.integrations.gitea.models import CommentInfo
|
||||
from agentci.workflows.render import JobRejected
|
||||
|
||||
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
|
||||
@@ -408,7 +409,7 @@ async def test_execute_completes_with_workflow_comment(
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
return "final workflow body"
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch)
|
||||
execute = task()
|
||||
|
||||
await value._execute(execute, current)
|
||||
@@ -432,7 +433,7 @@ async def test_execute_records_expected_rejection(
|
||||
async def dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
raise JobRejected("pull request is closed")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
@@ -452,7 +453,7 @@ async def test_execute_records_failure_at_latest_persisted_stage(
|
||||
repository.job = replace(repository.job, stage="cloning")
|
||||
raise RuntimeError("provider\nfailed")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", dispatch)
|
||||
monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch)
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(task(), current)
|
||||
|
||||
@@ -471,7 +472,7 @@ async def test_execute_marks_running_job_interrupted_after_restart(
|
||||
async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str:
|
||||
pytest.fail("running jobs must not be dispatched again")
|
||||
|
||||
monkeypatch.setattr("agentci.worker.dispatch", unexpected_dispatch)
|
||||
monkeypatch.setattr("agentci.application.worker.execution.dispatch", unexpected_dispatch)
|
||||
execute = task()
|
||||
|
||||
await make_worker(tmp_path, repository)._execute(execute, current)
|
||||
@@ -642,7 +643,7 @@ async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_safe_error_is_single_line_and_bounded() -> None:
|
||||
value = _safe_error(RuntimeError("bad\n" + "x" * 2000))
|
||||
value = safe_error(RuntimeError("bad\n" + "x" * 2000))
|
||||
|
||||
assert "\n" not in value
|
||||
assert len(value) == 1000
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.implementation import implement
|
||||
from agentci.workflows.model import AgentResult, ReviewReport
|
||||
from agentci.workflows.render import JobRejected
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
|
||||
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
|
||||
from agentci.workflows.render import JobRejected
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
|
||||
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
|
||||
from agentci.engine.run import JobRun
|
||||
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
|
||||
from agentci.workflows.model import AgentResult, ReviewReport
|
||||
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
|
||||
from agentci.workflows.render import JobRejected
|
||||
|
||||
Reference in New Issue
Block a user