Implement executing tasks
This commit is contained in:
@@ -4,6 +4,7 @@ import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.messaging import ai as ai_runner
|
||||
@@ -14,11 +15,13 @@ from core.models import (
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
ExternalChatLink,
|
||||
Message,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskProviderConfig,
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
||||
@@ -218,6 +221,64 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
provider = get_provider(provider_name)
|
||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||
variants = _channel_variants(task.source_service or "", task.source_channel or "")
|
||||
person_identifier = None
|
||||
if variants:
|
||||
person_identifier = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
user=task.user,
|
||||
service=task.source_service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)()
|
||||
external_chat_id = ""
|
||||
if person_identifier is not None:
|
||||
link = await sync_to_async(
|
||||
lambda: ExternalChatLink.objects.filter(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(
|
||||
Q(person_identifier=person_identifier)
|
||||
| Q(person=person_identifier.person)
|
||||
)
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)()
|
||||
if link is not None:
|
||||
external_chat_id = str(link.external_chat_id or "").strip()
|
||||
|
||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
"user": task.user,
|
||||
"task": task,
|
||||
"task_event": event,
|
||||
"provider": provider_name,
|
||||
"status": "pending",
|
||||
"payload": {
|
||||
"action": action,
|
||||
"provider_payload": {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
},
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(provider_settings, {
|
||||
@@ -225,18 +286,27 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
elif action == "complete":
|
||||
result = provider.mark_complete(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
else:
|
||||
result = provider.append_update(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import TaskProvider
|
||||
from .codex_cli import CodexCLITaskProvider
|
||||
from .mock import MockTaskProvider
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
"codex_cli": CodexCLITaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> TaskProvider:
|
||||
key = str(name or "").strip().lower()
|
||||
return PROVIDERS.get(key, PROVIDERS["mock"])
|
||||
|
||||
|
||||
def list_providers() -> list[TaskProvider]:
|
||||
return list(PROVIDERS.values())
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProviderResult:
|
||||
|
||||
class TaskProvider:
|
||||
name = "base"
|
||||
run_in_worker = False
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
117
core/tasks/providers/codex_cli.py
Normal file
117
core/tasks/providers/codex_cli.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
|
||||
class CodexCLITaskProvider(TaskProvider):
|
||||
name = "codex_cli"
|
||||
run_in_worker = True
|
||||
|
||||
def _timeout(self, config: dict) -> int:
|
||||
try:
|
||||
return max(1, int(config.get("timeout_seconds") or 60))
|
||||
except Exception:
|
||||
return 60
|
||||
|
||||
def _command(self, config: dict) -> str:
|
||||
return str(config.get("command") or "codex").strip() or "codex"
|
||||
|
||||
def _workspace(self, config: dict) -> str:
|
||||
return str(config.get("workspace_root") or "").strip()
|
||||
|
||||
def _profile(self, config: dict) -> str:
|
||||
return str(config.get("default_profile") or "").strip()
|
||||
|
||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
||||
cmd = [self._command(config), "task-sync", "--op", str(op)]
|
||||
workspace = self._workspace(config)
|
||||
if workspace:
|
||||
cmd.extend(["--workspace", workspace])
|
||||
profile = self._profile(config)
|
||||
if profile:
|
||||
cmd.extend(["--profile", profile])
|
||||
command_timeout = self._timeout(config)
|
||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
||||
cmd.extend(["--payload-json", data])
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
ok=False,
|
||||
error=f"codex_cli_timeout_{command_timeout}s",
|
||||
payload={"op": op, "timeout_seconds": command_timeout},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op})
|
||||
|
||||
stdout = str(completed.stdout or "").strip()
|
||||
stderr = str(completed.stderr or "").strip()
|
||||
parsed = {}
|
||||
if stdout:
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"raw_stdout": stdout}
|
||||
except Exception:
|
||||
parsed = {"raw_stdout": stdout}
|
||||
|
||||
ext = (
|
||||
str(parsed.get("external_key") or "").strip()
|
||||
or str(parsed.get("task_id") or "").strip()
|
||||
or str(payload.get("external_key") or "").strip()
|
||||
)
|
||||
|
||||
ok = completed.returncode == 0
|
||||
out_payload = {
|
||||
"op": op,
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": stdout[:4000],
|
||||
"stderr": stderr[:4000],
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
command = self._command(config)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(1, min(20, self._timeout(config))),
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"codex_cli_unavailable:{exc}")
|
||||
return ProviderResult(
|
||||
ok=(completed.returncode == 0),
|
||||
payload={
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
||||
},
|
||||
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]),
|
||||
)
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "create", payload)
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "append_update", payload)
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "mark_complete", payload)
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "link_task", payload)
|
||||
@@ -23,12 +23,3 @@ class MockTaskProvider(TaskProvider):
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "link_task"})
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> TaskProvider:
|
||||
return PROVIDERS.get(str(name or "").strip().lower(), PROVIDERS["mock"])
|
||||
|
||||
Reference in New Issue
Block a user