Add structured step logging and error reporting

This commit is contained in:
2026-07-19 12:50:09 +02:00
parent e9b895366d
commit 276b5590b5
11 changed files with 289 additions and 46 deletions
+28 -1
View File
@@ -2,14 +2,17 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from time import monotonic
from typing import TypeVar from typing import TypeVar
from pydantic import BaseModel from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__)
class CodexError(RuntimeError): class CodexError(RuntimeError):
@@ -113,6 +116,9 @@ class CodexClient:
async def _invoke( async def _invoke(
self, args: list[str], prompt: str, result_type: type[T] self, args: list[str], prompt: str, result_type: type[T]
) -> tuple[str | None, T]: ) -> 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( file_descriptor, output_name = tempfile.mkstemp(
prefix="agentci-codex-", suffix=".json" prefix="agentci-codex-", suffix=".json"
) )
@@ -134,14 +140,31 @@ class CodexClient:
except TimeoutError as exc: except TimeoutError as exc:
process.terminate() process.terminate()
await process.wait() 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 raise CodexError(f"Codex turn exceeded {self.timeout_seconds} seconds") from exc
if process.returncode: if process.returncode:
detail = stderr.decode(errors="replace").strip() 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:]}") raise CodexError(f"Codex exited with {process.returncode}: {detail[-2000:]}")
session_id = _session_id(stdout.decode(errors="replace")) session_id = _session_id(stdout.decode(errors="replace"))
text = output_path.read_text() # noqa: ASYNC240 - tiny host-owned result file text = output_path.read_text() # noqa: ASYNC240 - tiny host-owned result file
return session_id, result_type.model_validate_json(text) 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: 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 raise CodexError(f"Invalid Codex result: {exc}") from exc
finally: finally:
output_path.unlink(missing_ok=True) # noqa: ASYNC240 output_path.unlink(missing_ok=True) # noqa: ASYNC240
@@ -162,3 +185,7 @@ def _session_id(output: str) -> str | None:
if event.get("type") == "thread.started": if event.get("type") == "thread.started":
return str(event["thread_id"]) return str(event["thread_id"])
return None return None
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
+11 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import sqlite3 import sqlite3
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -7,6 +8,7 @@ from pathlib import Path
from typing import TypeVar from typing import TypeVar
T = TypeVar("T") T = TypeVar("T")
log = logging.getLogger(__name__)
def now() -> str: def now() -> str:
@@ -19,8 +21,16 @@ class Database:
self.migrations_dir = migrations_dir self.migrations_dir = migrations_dir
async def initialize(self) -> None: async def initialize(self) -> None:
log.info("database initialization started", extra={"operation": "database.initialize"})
self.database_path.parent.mkdir(parents=True, exist_ok=True) self.database_path.parent.mkdir(parents=True, exist_ok=True)
await self._run(self._initialize_sync) try:
await self._run(self._initialize_sync)
except Exception:
log.exception(
"database initialization failed", extra={"operation": "database.initialize"}
)
raise
log.info("database initialization completed", extra={"operation": "database.initialize"})
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=30) connection = sqlite3.connect(self.database_path, timeout=30)
+35 -9
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import os import os
from pathlib import Path from pathlib import Path
from time import monotonic
log = logging.getLogger(__name__)
class GitError(RuntimeError): class GitError(RuntimeError):
@@ -98,6 +102,9 @@ class GitClient:
cwd: Path, cwd: Path,
authenticated: bool = False, authenticated: bool = False,
) -> str: ) -> str:
operation = f"git.{args[0]}"
started = monotonic()
log.info("git step started", extra={"operation": operation})
environment = os.environ.copy() environment = os.environ.copy()
if authenticated: if authenticated:
environment.update( environment.update(
@@ -108,16 +115,35 @@ class GitClient:
"AGENTCI_GIT_PASSWORD": self.token, "AGENTCI_GIT_PASSWORD": self.token,
} }
) )
process = await asyncio.create_subprocess_exec( try:
"git", process = await asyncio.create_subprocess_exec(
*args, "git",
cwd=cwd, *args,
env=environment, cwd=cwd,
stdout=asyncio.subprocess.PIPE, env=environment,
stderr=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
) stderr=asyncio.subprocess.PIPE,
stdout, stderr = await process.communicate() )
stdout, stderr = await process.communicate()
except OSError as exc:
log.exception(
"git step could not start",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise GitError(f"Could not run git {args[0]}: {exc}") from exc
if process.returncode: if process.returncode:
detail = stderr.decode(errors="replace").strip() detail = stderr.decode(errors="replace").strip()
log.error(
"git step failed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
raise GitError(f"git {args[0]} failed: {detail[-1000:]}") raise GitError(f"git {args[0]} failed: {detail[-1000:]}")
log.info(
"git step completed",
extra={"operation": operation, "duration_ms": _elapsed_ms(started)},
)
return stdout.decode(errors="replace") return stdout.decode(errors="replace")
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
+29
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from time import monotonic
from typing import Any from typing import Any
import httpx import httpx
@@ -12,6 +14,8 @@ from agentci.adapters.gitea_models import (
RepositoryInfo, RepositoryInfo,
) )
log = logging.getLogger(__name__)
class GiteaError(RuntimeError): class GiteaError(RuntimeError):
pass pass
@@ -138,23 +142,45 @@ class GiteaClient:
**kwargs: Any, **kwargs: Any,
) -> httpx.Response: ) -> httpx.Response:
for attempt in range(self.retries): for attempt in range(self.retries):
started = monotonic()
extra = {
"operation": "gitea.request",
"method": method,
"path": path,
"attempt": attempt + 1,
}
log.info("Gitea request started", extra=extra)
try: try:
response = await self.client.request(method, path, **kwargs) response = await self.client.request(method, path, **kwargs)
except httpx.RequestError as exc: except httpx.RequestError as exc:
log.warning(
"Gitea request transport failure",
extra={**extra, "duration_ms": _elapsed_ms(started)},
exc_info=exc,
)
if attempt == self.retries - 1: if attempt == self.retries - 1:
raise GiteaError(f"Gitea request failed: {method} {path}") from exc raise GiteaError(f"Gitea request failed: {method} {path}") from exc
await asyncio.sleep(2**attempt) await asyncio.sleep(2**attempt)
continue continue
response_extra = {
**extra,
"status_code": response.status_code,
"duration_ms": _elapsed_ms(started),
}
if response.status_code == 404 and allow_not_found: if response.status_code == 404 and allow_not_found:
log.info("Gitea request completed", extra=response_extra)
return response return response
if response.status_code not in {429, 500, 502, 503, 504}: if response.status_code not in {429, 500, 502, 503, 504}:
try: try:
response.raise_for_status() response.raise_for_status()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
log.error("Gitea request rejected", extra=response_extra)
raise GiteaError( raise GiteaError(
f"Gitea returned {response.status_code} for {method} {path}" f"Gitea returned {response.status_code} for {method} {path}"
) from exc ) from exc
log.info("Gitea request completed", extra=response_extra)
return response return response
log.warning("Gitea request will be retried", extra=response_extra)
if attempt < self.retries - 1: if attempt < self.retries - 1:
await asyncio.sleep(2**attempt) await asyncio.sleep(2**attempt)
raise GiteaError(f"Gitea remained unavailable for {method} {path}") raise GiteaError(f"Gitea remained unavailable for {method} {path}")
@@ -168,3 +194,6 @@ def _comment(data: dict[str, Any]) -> CommentInfo:
created_at=data.get("created_at") or "", created_at=data.get("created_at") or "",
) )
def _elapsed_ms(started: float) -> int:
return round((monotonic() - started) * 1000)
+13
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging
import sqlite3 import sqlite3
from agentci.adapters.database import Database, now from agentci.adapters.database import Database, now
from agentci.domain.models import Job, JobKind, JobStatus from agentci.domain.models import Job, JobKind, JobStatus
log = logging.getLogger(__name__)
class JobStore(Database): class JobStore(Database):
async def record_delivery(self, delivery_id: str, comment_id: int) -> bool: async def record_delivery(self, delivery_id: str, comment_id: int) -> bool:
@@ -101,6 +104,16 @@ class JobStore(Database):
if workflow_id is not None: if workflow_id is not None:
updates["workflow_id"] = workflow_id updates["workflow_id"] = workflow_id
await self._update("jobs", job_id, updates) 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 set_job_comment(self, job_id: str, column: str, comment_id: int) -> None:
if column not in {"accepted_comment_id", "started_comment_id"}: if column not in {"accepted_comment_id", "started_comment_id"}:
+26
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import json import json
import logging
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -12,6 +13,7 @@ from agentci.domain.commands import CommandError, parse_command, resolve_job_kin
from agentci.domain.models import CommandEvent, Job, JobStatus from agentci.domain.models import CommandEvent, Job, JobStatus
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__)
SUPPORTED_EVENTS = { SUPPORTED_EVENTS = {
"issue_comment", "issue_comment",
"pull_request_comment", "pull_request_comment",
@@ -25,11 +27,19 @@ async def webhook(request: Request) -> Response:
body = await request.body() body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "") signature = request.headers.get("X-Gitea-Signature", "")
if not valid_signature(container.settings.webhook_secret, body, signature): if not valid_signature(container.settings.webhook_secret, body, signature):
log.warning(
"webhook signature rejected",
extra={"operation": "webhook.verify", "path": request.url.path},
)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid webhook signature") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid webhook signature")
event_name = request.headers.get("X-Gitea-Event-Type") or request.headers.get( event_name = request.headers.get("X-Gitea-Event-Type") or request.headers.get(
"X-Gitea-Event", "" "X-Gitea-Event", ""
) )
if event_name not in SUPPORTED_EVENTS: if event_name not in SUPPORTED_EVENTS:
log.info(
"unsupported webhook ignored",
extra={"operation": "webhook.filter", "stage": event_name or "missing"},
)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
try: try:
payload = json.loads(body) payload = json.loads(body)
@@ -38,8 +48,14 @@ async def webhook(request: Request) -> Response:
payload, payload,
) )
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
log.warning(
"webhook payload rejected",
extra={"operation": "webhook.parse", "stage": event_name},
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 == container.settings.bot_username:
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)
@@ -50,12 +66,15 @@ def valid_signature(secret: bytes, body: bytes, signature: str) -> bool:
async def _handle_command(container: Any, event: CommandEvent) -> Response: async def _handle_command(container: Any, event: CommandEvent) -> Response:
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)
log.info("agent command received", extra=extra)
permitted = await container.gitea.has_write_permission( permitted = await container.gitea.has_write_permission(
event.repo_owner, event.repo_name, event.requester event.repo_owner, event.repo_name, event.requester
) )
if not permitted: if not permitted:
log.warning("agent command rejected: insufficient permission", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id): if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment( await container.gitea.create_comment(
event.repo_owner, event.repo_owner,
@@ -67,6 +86,7 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
try: try:
command = parse_command(event.body) command = parse_command(event.body)
except CommandError as exc: except CommandError as exc:
log.info("agent command rejected: invalid syntax", extra=extra)
if await container.storage.record_delivery(event.delivery_id, event.comment_id): if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment( await container.gitea.create_comment(
event.repo_owner, event.repo_name, event.issue_number, str(exc) event.repo_owner, event.repo_name, event.issue_number, str(exc)
@@ -77,6 +97,7 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
try: try:
kind = resolve_job_kind(command, is_pull_request=event.is_pull_request) kind = resolve_job_kind(command, is_pull_request=event.is_pull_request)
except CommandError as exc: 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): if await container.storage.record_delivery(event.delivery_id, event.comment_id):
await container.gitea.create_comment( await container.gitea.create_comment(
event.repo_owner, event.repo_name, event.issue_number, str(exc) event.repo_owner, event.repo_name, event.issue_number, str(exc)
@@ -97,7 +118,12 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response:
stage="queued", stage="queued",
) )
if not await container.storage.enqueue(event.delivery_id, job): 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(
"agent job queued",
extra={**extra, "job_id": job.id, "stage": job.kind.value},
)
comment_id = await container.gitea.create_comment( comment_id = await container.gitea.create_comment(
event.repo_owner, event.repo_owner,
event.repo_name, event.repo_name,
+34 -5
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from agentci.api.health import router as health_router from agentci.api.health import router as health_router
from agentci.api.webhook import router as webhook_router from agentci.api.webhook import router as webhook_router
@@ -12,6 +14,7 @@ from agentci.config import Settings
from agentci.container import build_container from agentci.container import build_container
from agentci.logging import configure_logging from agentci.logging import configure_logging
log = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
selected_settings = settings or Settings() selected_settings = settings or Settings()
@@ -19,22 +22,48 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configure_logging() configure_logging()
container = await build_container(selected_settings) log.info("service startup started", extra={"operation": "service.startup"})
try:
container = await build_container(selected_settings)
except Exception:
log.exception("service startup failed", extra={"operation": "service.startup"})
raise
app.state.container = container app.state.container = container
stop = asyncio.Event() stop = asyncio.Event()
worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker") worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker")
try: try:
log.info("service startup completed", extra={"operation": "service.startup"})
yield yield
finally: finally:
log.info("service shutdown started", extra={"operation": "service.shutdown"})
stop.set() stop.set()
await worker_task try:
await container.close() await worker_task
finally:
await container.close()
log.info("service shutdown completed", extra={"operation": "service.shutdown"})
app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan) app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan)
app.include_router(health_router) app.include_router(health_router)
app.include_router(webhook_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 return app
app = create_app() app = create_app()
+8 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -14,6 +15,7 @@ from agentci.workflows.common import Dependencies
from agentci.workflows.context import ContextBuilder from agentci.workflows.context import ContextBuilder
from agentci.workflows.dispatcher import Dispatcher from agentci.workflows.dispatcher import Dispatcher
log = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class Container: class Container:
@@ -25,10 +27,13 @@ class Container:
worker: Worker worker: Worker
async def close(self) -> None: async def close(self) -> None:
log.info("container shutdown started", extra={"operation": "container.close"})
await self.gitea.close() await self.gitea.close()
log.info("container shutdown completed", extra={"operation": "container.close"})
async def build_container(settings: Settings) -> Container: async def build_container(settings: Settings) -> Container:
log.info("container initialization started", extra={"operation": "container.build"})
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)
@@ -68,4 +73,6 @@ async def build_container(settings: Settings) -> Container:
dispatcher=dispatcher, dispatcher=dispatcher,
poll_seconds=settings.worker_poll_seconds, poll_seconds=settings.worker_poll_seconds,
) )
return Container(settings, storage, gitea, git, codex, worker) container = Container(settings, storage, gitea, git, codex, worker)
log.info("container initialization completed", extra={"operation": "container.build"})
return container
+23 -4
View File
@@ -2,9 +2,24 @@ from __future__ import annotations
import json import json
import logging import logging
import traceback
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
LOG_FIELDS = (
"operation",
"job_id",
"workflow_id",
"stage",
"target",
"method",
"path",
"status_code",
"attempt",
"item_count",
"duration_ms",
)
class JsonFormatter(logging.Formatter): class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
@@ -14,12 +29,17 @@ class JsonFormatter(logging.Formatter):
"logger": record.name, "logger": record.name,
"message": record.getMessage(), "message": record.getMessage(),
} }
for key in ("job_id", "stage", "target"): for key in LOG_FIELDS:
if value := getattr(record, key, None): if (value := getattr(record, key, None)) is not None:
payload[key] = value payload[key] = value
if record.exc_info: if record.exc_info:
payload["exception"] = self.formatException(record.exc_info) payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False) try:
return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
payload["message"] = "Log record could not be serialized"
payload["exception"] = traceback.format_exc()
return json.dumps(payload, ensure_ascii=False)
def configure_logging() -> None: def configure_logging() -> None:
@@ -29,4 +49,3 @@ def configure_logging() -> None:
root.handlers.clear() root.handlers.clear()
root.addHandler(handler) root.addHandler(handler)
root.setLevel(logging.INFO) root.setLevel(logging.INFO)
+65 -23
View File
@@ -31,16 +31,27 @@ class Worker:
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
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._report_interrupted() await self._report_interrupted()
while not stop.is_set(): try:
if not await self.codex.login_ready(): while not stop.is_set():
await self._wait(stop) if not await self.codex.login_ready():
continue log.warning(
job = await self.storage.claim_next() "worker waiting for Codex authentication",
if job is None: extra={"operation": "worker.poll"},
await self._wait(stop) )
continue await self._wait(stop)
await self._run_job(job) 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 _run_job(self, job: Job) -> None:
extra = {"job_id": job.id, "target": job.target_key} extra = {"job_id": job.id, "target": job.target_key}
@@ -65,34 +76,37 @@ class Worker:
await self.storage.set_job_comment(job.id, "started_comment_id", comment_id) await self.storage.set_job_comment(job.id, "started_comment_id", comment_id)
await self.dispatcher.dispatch(job) await self.dispatcher.dispatch(job)
except JobRejected as exc: except JobRejected as exc:
await self.storage.update_job( await self._safe_update_job(
job.id, status=JobStatus.REJECTED, stage="rejected", error=str(exc) job, status=JobStatus.REJECTED, stage="rejected", error=str(exc)
) )
await self._safe_comment(job, f"Agent job `{job.id}` was rejected: {exc}") await self._safe_comment(job, f"Agent job `{job.id}` was rejected: {exc}")
log.info("job rejected", extra=extra) log.info("job rejected", extra={**extra, "stage": "rejected"})
except Exception as exc: except Exception as exc:
failed_stage = await self.storage.job_stage(job.id) failed_stage = await self._safe_job_stage(job)
await self.storage.update_job( await self._safe_update_job(
job.id, job, status=JobStatus.FAILED, stage="failed", error=_safe_error(exc)
status=JobStatus.FAILED,
stage="failed",
error=_safe_error(exc),
) )
await self.storage.fail_job_workflow(job.id) await self._safe_fail_workflow(job)
await self._safe_comment( await self._safe_comment(
job, job,
f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}", f"Agent job `{job.id}` failed during `{failed_stage}`: {_safe_error(exc)}",
) )
log.exception("job failed", extra={**extra, "stage": failed_stage}) log.exception("job failed", extra={**extra, "stage": failed_stage})
else: else:
await self.storage.update_job( await self._safe_update_job(
job.id, status=JobStatus.SUCCEEDED, stage="completed" job, status=JobStatus.SUCCEEDED, stage="completed"
) )
log.info("job completed", extra=extra) log.info("job completed", extra=extra)
async def _report_interrupted(self) -> None: async def _report_interrupted(self) -> None:
for job in await self.storage.recover_running(): jobs = await self.storage.recover_running()
await self.storage.fail_job_workflow(job.id) if jobs:
log.warning(
"recovering interrupted jobs",
extra={"operation": "worker.recover", "item_count": len(jobs)},
)
for job in jobs:
await self._safe_fail_workflow(job)
await self._safe_comment( await self._safe_comment(
job, job,
f"Agent job `{job.id}` failed because the service restarted during execution.", f"Agent job `{job.id}` failed because the service restarted during execution.",
@@ -106,6 +120,34 @@ class Worker:
except Exception: except Exception:
log.exception("could not publish job status", extra={"job_id": job.id}) log.exception("could not publish job status", extra={"job_id": job.id})
async def _safe_job_stage(self, job: Job) -> str:
try:
return await self.storage.job_stage(job.id)
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:
log.exception("could not persist job status", extra={"job_id": job.id})
async def _safe_fail_workflow(self, job: Job) -> None:
try:
await self.storage.fail_job_workflow(job.id)
except Exception:
log.exception("could not mark workflow failed", extra={"job_id": job.id})
async def _wait(self, stop: asyncio.Event) -> None: async def _wait(self, stop: asyncio.Event) -> None:
with suppress(TimeoutError): with suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds) await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds)
+17 -2
View File
@@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
import logging
from agentci.domain.models import Job, JobKind from agentci.domain.models import Job, JobKind
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
from agentci.workflows.pull_request import PullRequestWorkflow from agentci.workflows.pull_request import PullRequestWorkflow
log = logging.getLogger(__name__)
class Dispatcher: class Dispatcher:
def __init__(self, dependencies: Dependencies) -> None: def __init__(self, dependencies: Dependencies) -> None:
@@ -21,5 +25,16 @@ class Dispatcher:
} }
async def dispatch(self, job: Job) -> None: async def dispatch(self, job: Job) -> None:
await self.handlers[job.kind](job) extra = {
"operation": "workflow.dispatch",
"job_id": job.id,
"target": job.target_key,
"stage": job.kind.value,
}
log.info("workflow dispatch started", extra=extra)
try:
await self.handlers[job.kind](job)
except Exception:
log.exception("workflow dispatch failed", extra=extra)
raise
log.info("workflow dispatch completed", extra=extra)